diff --git a/.gitignore b/.gitignore index 45c49be..e92f818 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ dist .DS_Store .claude/settings.local.json packages/ghost-ui/public/r/ +.env +.env.local +packages/ghost-ui/.ghost/ diff --git a/.npmrc b/.npmrc index f7bafc7..bacea71 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,3 @@ shamefully-hoist=false strict-peer-dependencies=true +@anthropic-ai:registry=https://registry.npmjs.org diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e491ef6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,112 @@ +# Ghost — Agent Context + +## Build & Run + +```bash +pnpm install # install dependencies (pnpm 10+, Node 18+) +pnpm build # build all packages (tsc --build) +``` + +Run the CLI after building: + +```bash +node packages/ghost-cli/dist/bin.js +# or +pnpm --filter ghost-cli exec ghost +``` + +## Environment Variables + +- `ANTHROPIC_API_KEY` — required for AI-powered profiling (`--ai` flag) and LLM agents +- `OPENAI_API_KEY` — alternative LLM provider +- `GITHUB_TOKEN` — optional, for GitHub target resolution and discovery (avoids rate limits) + +The CLI auto-loads `.env` and `.env.local` from the working directory. + +## Test & Lint + +```bash +pnpm test # vitest run +pnpm test:watch # vitest watch mode +pnpm check # biome check + typecheck + file-size check +pnpm fmt # biome format --write +pnpm lint # biome lint +``` + +Pre-commit hook (lefthook): `biome format --write`, `biome check --fix`, `just check`. +Pre-push hook: `just check`, `just test`, `just build` (parallel). + +## Justfile + +Run `just` to list all recipes. Key ones: `setup`, `build`, `check`, `fmt`, `test`, `dev` (ghost-ui catalogue), `build-ui`, `build-registry`, `clean`, `ci`. + +## Architecture + +**Director** (`packages/ghost-core/src/agents/director.ts`) orchestrates the pipeline: + +- **Stages** (`packages/ghost-core/src/stages/`) — deterministic async functions: `extract`, `compare`, `comply` +- **Agents** (`packages/ghost-core/src/agents/`) — LLM-powered steps: `FingerprintAgent`, `DiscoveryAgent`, `ComparisonAgent`, `ComplianceAgent`, `ExtractionAgent` + +Typical pipeline: `target → extract (stage) → fingerprint (agent) → compare/comply (stage)` + +## Packages + +| Package | Description | +|---------|-------------| +| `packages/ghost-core` | Core library: agents, stages, fingerprinting, scanners, extractors, evolution, LLM providers, reporters | +| `packages/ghost-cli` | CLI (citty-based), 12 subcommands | +| `packages/ghost-ui` | Reference design language — 97 shadcn-compatible components, design tokens, live catalogue | +| `packages/ghost-mcp` | MCP server exposing Ghost UI registry to AI assistants (6 tools, 2 resources) | +| `action/` | GitHub Action for automated PR design review | + +## CLI Commands + +| Command | Description | +|---------|-------------| +| `ghost review [files]` | Review files for visual language drift against a fingerprint (zero-config) | +| `ghost scan` | Scan for design drift (requires `ghost.config.ts`) | +| `ghost profile [target]` | Generate a fingerprint — accepts paths, `github:owner/repo`, `npm:package`, URLs | +| `ghost compare ` | Compare two fingerprint JSON files | +| `ghost diff [component]` | Compare local components against registry | +| `ghost comply [target]` | Check compliance; `--against parent.json` for drift checking | +| `ghost discover [query]` | Find public design systems | +| `ghost fleet ...` | Ecosystem-level comparison (2+ fingerprint files) | +| `ghost ack` | Acknowledge drift, record stance (aligned/accepted/diverging) | +| `ghost adopt ` | Adopt a new parent baseline | +| `ghost diverge ` | Declare intentional divergence with reasoning | +| `ghost viz ...` | 3D fingerprint visualization (Three.js) | + +## Target Types + +The `resolveTarget()` function in `packages/ghost-core/src/config.ts` accepts: + +- `github:owner/repo` — GitHub repository +- `npm:package-name` — npm package +- `figma:file-url` — Figma file +- `./path` or `/absolute/path` — local directory +- `https://...` — URL +- `.` — current directory (default for `profile` and `comply`) + +Use explicit prefixes when the input is ambiguous. + +## Review Pipeline + +The `review` module (`packages/ghost-core/src/review/`) provides fingerprint-informed design review: + +- **matcher.ts** — deterministic scan: match hardcoded values against fingerprint palette/spacing/typography/surfaces +- **deep-review.ts** — LLM-powered nuanced drift detection (optional, `--deep` flag) +- **file-collector.ts** — git diff parsing to resolve changed files and line numbers +- **pipeline.ts** — orchestrates: resolve fingerprint → collect files → match → (optional) deep review → report + +Zero-config: `ghost review` looks for `.ghost-fingerprint.json` in cwd. Generate with `ghost profile . --emit`. + +## Key Conventions + +- Fingerprints are 64-dimensional vectors stored as JSON (`DesignFingerprint` type) +- `compare`, `fleet`, and `viz` commands take **file paths** to fingerprint JSON, not target strings +- `profile` outputs fingerprints; pipe to `--output ` to save for later comparison +- `--against` on `comply` takes a **file path** to a parent fingerprint JSON +- `--ai` enables LLM-powered enrichment on `profile`; `--verbose` shows agent reasoning +- `review` reads `.ghost-fingerprint.json` by default; `--fingerprint ` overrides +- `review --deep` requires `ANTHROPIC_API_KEY` for LLM-powered nuanced analysis +- `review --staged` checks only staged changes; `--base main` diffs against a branch diff --git a/README.md b/README.md index 3362dab..01f9717 100644 --- a/README.md +++ b/README.md @@ -32,29 +32,51 @@ pnpm build ### Quick Start -**Scan for drift:** +**Profile a design system:** ```bash -ghost scan --config ghost.config.ts -``` +# Profile the current directory +ghost profile . -**Generate a design fingerprint:** +# Profile a GitHub repo +ghost profile github:shadcn-ui/ui -```bash -ghost profile --config ghost.config.ts -# or from a shadcn registry directly +# Profile with AI enrichment (requires ANTHROPIC_API_KEY or OPENAI_API_KEY) +ghost profile github:shadcn-ui/ui --ai --verbose + +# Profile a shadcn registry directly ghost profile --registry https://ui.shadcn.com/registry.json + +# Save fingerprint to a file +ghost profile . --output my-system.json ``` **Compare two fingerprints:** ```bash -ghost compare system-a.json system-b.json +# Profile two systems, then compare +ghost profile github:shadcn-ui/ui --output shadcn.json +ghost profile npm:@chakra-ui/react --output chakra.json +ghost compare shadcn.json chakra.json +``` + +**Check compliance against a parent:** + +```bash +ghost comply . --against parent-fingerprint.json +ghost comply . --against parent-fingerprint.json --format sarif ``` -**Visualize a fleet:** +**Scan for drift (config-based):** ```bash +ghost scan --config ghost.config.ts +``` + +**Fleet observability and visualization:** + +```bash +ghost fleet system-a.json system-b.json system-c.json --cluster ghost viz system-a.json system-b.json system-c.json ``` @@ -67,46 +89,86 @@ just dev ## CLI Commands -| Command | Description | -| --------------- | ---------------------------------------------------------------------------- | -| `ghost scan` | Detect design drift against a registry | -| `ghost profile` | Generate a design fingerprint from a registry, codebase, or via LLM | -| `ghost compare` | Compare two fingerprints with optional temporal analysis | -| `ghost ack` | Acknowledge current drift and publish a stance (aligned, accepted, diverging) | -| `ghost adopt` | Shift parent baseline to a new fingerprint | -| `ghost diverge` | Mark a fingerprint dimension as intentionally diverging with reasoning | -| `ghost fleet` | Compare N fingerprints for ecosystem-wide observability | -| `ghost viz` | Launch interactive 3D fingerprint visualization | +| Command | Description | +| ---------------- | -------------------------------------------------------------------------------- | +| `ghost scan` | Scan for design drift against a registry | +| `ghost profile` | Generate a fingerprint for any target (directory, URL, npm package, GitHub repo) | +| `ghost compare` | Compare two fingerprint JSON files with optional temporal analysis | +| `ghost diff` | Compare local components against registry with drift analysis | +| `ghost comply` | Check design system compliance against rules and a parent fingerprint | +| `ghost discover` | Find public design systems matching a query | +| `ghost ack` | Acknowledge current drift — record intentional stance toward parent | +| `ghost adopt` | Shift parent baseline to a new fingerprint | +| `ghost diverge` | Declare intentional divergence on a dimension with reasoning | +| `ghost fleet` | Compare N fingerprint files for ecosystem-level observability | +| `ghost viz` | Launch interactive 3D fingerprint visualization | + +### Target Types + +`ghost profile` and `ghost comply` accept universal targets: + +```bash +ghost profile . # current directory +ghost profile ./path/to/project # local path +ghost profile github:shadcn-ui/ui # GitHub repo +ghost profile npm:@chakra-ui/react # npm package +ghost profile https://example.com # URL +ghost profile --registry registry.json # shadcn registry directly +``` + +Use explicit prefixes (`github:`, `npm:`, `figma:`, `path:`, `url:`) when the input is ambiguous. ## Configuration -Create a `ghost.config.ts` in your project root: +Optionally create a `ghost.config.ts` in your project root to configure scanning targets, rules, and LLM settings. ```typescript import { defineConfig } from "@ghost/core"; export default defineConfig({ - parent: "default", - designSystems: [ - { - name: "my-ui", - registry: "https://ui.shadcn.com/registry.json", - componentDir: "components/ui", - styleEntry: "src/styles/main.css", - }, + // Parent design system to check drift against + parent: { type: "github", value: "shadcn-ui/ui" }, + + // Targets to scan + targets: [ + { type: "path", value: "./packages/my-ui" }, ], + scan: { values: true, structure: true, visual: false, + analysis: false, }, + rules: { "hardcoded-color": "error", "token-override": "warn", "missing-token": "warn", - "structural-divergence": "warn", + "structural-divergence": "error", "missing-component": "warn", + "visual-regression": "warn", }, + + ignore: [], + + // LLM provider for AI-powered profiling (optional) + llm: { + provider: "anthropic", + // model: "claude-sonnet-4-20250514", // optional override + // apiKey: "..." // defaults to ANTHROPIC_API_KEY env var + }, + + // Embedding provider for semantic comparison (optional) + // embedding: { + // provider: "openai", + // }, + + // Agent settings (optional) + // agents: { + // maxIterations: 40, + // verbose: true, + // }, }); ``` @@ -187,12 +249,27 @@ just build-ui just build-registry ``` +## Ghost MCP + +Ghost MCP (`@ghost/mcp`) is a Model Context Protocol server that exposes the Ghost UI registry to AI assistants. + +**Tools:** `search_components`, `get_component`, `get_install_command`, `list_categories`, `get_theme` + +**Resources:** `ghost://registry` (full registry JSON), `ghost://skills` (skill docs) + +```bash +# Run the MCP server (stdio transport) +node packages/ghost-mcp/dist/bin.js +``` + ## Project Structure ``` packages/ ghost-core/ Core library src/ + agents/ Director, FingerprintAgent, DiscoveryAgent, ComparisonAgent, ComplianceAgent + stages/ Deterministic pipeline stages (extract, compare, comply) fingerprint/ Fingerprinting engine (embedding, comparison, extraction) evolution/ Evolution tracking (sync, temporal, fleet, history) scanners/ Drift scanners (values, structure, visual) @@ -200,9 +277,15 @@ packages/ resolvers/ Registry and CSS resolution llm/ LLM providers (Anthropic, OpenAI) reporters/ Output formatting (CLI, JSON, fingerprint, fleet) - ghost-cli/ CLI interface + ghost-cli/ CLI interface (citty) src/ + bin.ts Command definitions (scan, profile, compare, diff, comply, discover, etc.) + evolution-commands.ts ack, adopt, diverge, fleet commands viz/ 3D visualization (Three.js, PCA projection) + ghost-mcp/ MCP server for Ghost UI registry + src/ + tools.ts 5 MCP tools (search, get, install, categories, theme) + resources.ts 2 MCP resources (registry, skills) ghost-ui/ Reference design language (@ghost/ui) src/ components/ @@ -217,6 +300,11 @@ packages/ styles/ Design tokens and global CSS fonts/ HK Grotesk woff2 files registry.json shadcn-compatible component registry +skills/ Claude Code skill definitions + ghost-fingerprint/ Profile any design system + ghost-compare/ Compare two design systems + ghost-drift-check/ Check design compliance + ghost-discover/ Find public design systems ``` ## Development diff --git a/action/action.yml b/action/action.yml new file mode 100644 index 0000000..890844c --- /dev/null +++ b/action/action.yml @@ -0,0 +1,37 @@ +name: "Ghost Design Review" +description: "Review pull requests for visual language drift against a design fingerprint" +author: "Ghost" + +inputs: + github-token: + description: "GitHub token for posting review comments" + required: true + default: ${{ github.token }} + fingerprint: + description: "Path to fingerprint JSON file" + required: false + default: ".ghost-fingerprint.json" + anthropic-api-key: + description: "Anthropic API key (or set ANTHROPIC_API_KEY env var)" + required: false + dimensions: + description: "Comma-separated dimensions to check: palette,spacing,typography,surfaces" + required: false + base: + description: "Base ref for diff" + required: false + default: ${{ github.event.pull_request.base.sha }} + +outputs: + issues-found: + description: "Number of issues found" + has-errors: + description: "Whether any errors were found (true/false)" + +runs: + using: "node20" + main: "dist/index.js" + +branding: + icon: "eye" + color: "purple" diff --git a/action/index.ts b/action/index.ts new file mode 100644 index 0000000..9b46f96 --- /dev/null +++ b/action/index.ts @@ -0,0 +1,102 @@ +/** + * Ghost Design Review — GitHub Action entrypoint. + * + * Runs fingerprint-informed review on PR changed files and posts + * inline suggestions as a GitHub PR review. + * + * Usage in workflow: + * + * - uses: block/ghost@v1 + * with: + * github-token: ${{ secrets.GITHUB_TOKEN }} + * + * Requires .ghost-fingerprint.json in the repo (run `ghost profile . --emit`). + */ + +import * as core from "@actions/core"; +import * as github from "@actions/github"; +import { + formatGitHubPRComments, + formatReviewSummary, + review, +} from "@ghost/core"; + +async function run() { + try { + const token = core.getInput("github-token", { required: true }); + const fingerprintPath = + core.getInput("fingerprint") || ".ghost-fingerprint.json"; + const anthropicApiKey = + core.getInput("anthropic-api-key") || process.env.ANTHROPIC_API_KEY; + const dimensionsInput = core.getInput("dimensions") || undefined; + const base = core.getInput("base") || undefined; + + // Parse dimensions + let dimensions: Record | undefined; + if (dimensionsInput) { + dimensions = {}; + for (const d of dimensionsInput.split(",")) { + const dim = d.trim(); + if (["palette", "spacing", "typography", "surfaces"].includes(dim)) { + dimensions[dim] = true; + } + } + for (const d of ["palette", "spacing", "typography", "surfaces"]) { + if (!dimensions[d]) dimensions[d] = false; + } + } + + const report = await review({ + diff: { base }, + fingerprintPath, + config: { + dimensions, + changedLinesOnly: true, + }, + llmConfig: anthropicApiKey + ? { provider: "anthropic", apiKey: anthropicApiKey } + : undefined, + }); + + // Set outputs + core.setOutput("issues-found", report.summary.totalIssues.toString()); + core.setOutput("has-errors", (report.summary.errors > 0).toString()); + + // Post PR review if we have issues and a PR context + const context = github.context; + if (context.payload.pull_request && report.summary.totalIssues > 0) { + const octokit = github.getOctokit(token); + const comments = formatGitHubPRComments(report); + const summaryBody = formatReviewSummary(report); + + await octokit.rest.pulls.createReview({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + event: "COMMENT", + body: summaryBody, + comments: comments.map((c) => ({ + path: c.path, + line: c.line, + side: c.side, + body: c.body, + })), + }); + + core.info(`Posted review with ${comments.length} inline comments.`); + } else if (report.summary.totalIssues === 0) { + core.info("No design drift detected."); + } + + // Fail the action if errors found + if (report.summary.errors > 0) { + core.setFailed( + `Ghost found ${report.summary.errors} design drift errors.`, + ); + } + } catch (error) { + core.setFailed(error instanceof Error ? error.message : String(error)); + } +} + +run(); diff --git a/action/package.json b/action/package.json new file mode 100644 index 0000000..cb4528b --- /dev/null +++ b/action/package.json @@ -0,0 +1,19 @@ +{ + "name": "ghost-action", + "version": "0.1.0", + "private": true, + "description": "Ghost Design Review GitHub Action", + "main": "dist/index.js", + "scripts": { + "build": "ncc build index.ts -o dist" + }, + "dependencies": { + "@actions/core": "^1.10.1", + "@actions/github": "^6.0.0", + "@ghost/core": "workspace:*" + }, + "devDependencies": { + "@vercel/ncc": "^0.38.0", + "typescript": "^5.7.0" + } +} diff --git a/biome.json b/biome.json index 165530e..8129c64 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.10/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.12/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/package.json b/package.json index 699d1c6..9f30b38 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,11 @@ "lint": "biome lint ." }, "devDependencies": { - "@biomejs/biome": "^2.4.0", + "@anthropic-ai/sdk": "^0.88.0", + "@biomejs/biome": "^2.4.12", "@types/node": "^22.0.0", "lefthook": "^2.1.0", + "tsx": "^4.21.0", "typescript": "^5.7.0", "vitest": "^3.0.0" } diff --git a/packages/ghost-cli/src/bin.ts b/packages/ghost-cli/src/bin.ts index 9104769..4af6760 100644 --- a/packages/ghost-cli/src/bin.ts +++ b/packages/ghost-cli/src/bin.ts @@ -1,7 +1,22 @@ #!/usr/bin/env node +import { existsSync } from "node:fs"; import { readFile, writeFile } from "node:fs/promises"; -import type { DesignFingerprint } from "@ghost/core"; +import { resolve } from "node:path"; + +// Load .env from project root if present. +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, EmbeddingConfig } from "@ghost/core"; import { compareFingerprints, computeTemporalComparison, @@ -9,8 +24,13 @@ import { formatCLIReport, formatComparison, formatComparisonJSON, + formatComplianceCLI, + formatComplianceJSON, + formatComplianceSARIF, formatDiffCLI, formatDiffJSON, + formatDiscoveryCLI, + formatDiscoveryJSON, formatFingerprint, formatFingerprintJSON, formatJSONReport, @@ -19,8 +39,10 @@ import { loadConfig, profile, profileRegistry, + profileTarget, readHistory, readSyncManifest, + resolveTarget, scan, } from "@ghost/core"; import { defineCommand, runMain } from "citty"; @@ -30,6 +52,7 @@ import { divergeCommand, fleetCommand, } from "./evolution-commands.js"; +import { reviewCommand } from "./review-command.js"; import { vizCommand } from "./viz-command.js"; const scanCommand = defineCommand({ @@ -78,9 +101,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", @@ -103,6 +133,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", @@ -114,23 +154,42 @@ 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 }); } @@ -290,17 +349,156 @@ 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, + review: reviewCommand, 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/review-command.ts b/packages/ghost-cli/src/review-command.ts new file mode 100644 index 0000000..7a36a1c --- /dev/null +++ b/packages/ghost-cli/src/review-command.ts @@ -0,0 +1,123 @@ +import { + formatGitHubPRComments, + formatReviewCLI, + formatReviewJSON, + formatReviewSummary, + review, +} from "@ghost/core"; +import { defineCommand } from "citty"; + +export const reviewCommand = defineCommand({ + meta: { + name: "review", + description: + "Review files for visual language drift against a design fingerprint", + }, + args: { + files: { + type: "positional", + description: "Files to review (omit to review git diff)", + required: false, + }, + fingerprint: { + type: "string", + description: + "Path to fingerprint JSON (default: .ghost-fingerprint.json)", + alias: "f", + }, + staged: { + type: "boolean", + description: "Review staged changes only", + default: false, + }, + base: { + type: "string", + description: "Base ref for git diff (default: HEAD)", + alias: "b", + }, + format: { + type: "string", + description: "Output format: cli, json, github (default: cli)", + default: "cli", + }, + dimensions: { + type: "string", + description: + "Comma-separated dimensions to check: palette,spacing,typography,surfaces", + }, + all: { + type: "boolean", + description: "Report issues on all lines, not just changed lines", + default: false, + }, + }, + async run({ args }) { + try { + // Parse dimensions flag + let dimensions: Record | undefined; + if (args.dimensions) { + dimensions = {}; + for (const d of (args.dimensions as string).split(",")) { + const dim = d.trim(); + if ( + dim === "palette" || + dim === "spacing" || + dim === "typography" || + dim === "surfaces" + ) { + dimensions[dim] = true; + } + } + // Explicitly disable unchecked dimensions + for (const d of ["palette", "spacing", "typography", "surfaces"]) { + if (!dimensions[d]) dimensions[d] = false; + } + } + + // Build file list from positional arg + const files = + args.files && typeof args.files === "string" + ? (args.files as string).split(",").map((f: string) => f.trim()) + : undefined; + + const report = await review({ + files: files && files.length > 0 ? files : undefined, + diff: + !files || files.length === 0 + ? { + base: args.base as string | undefined, + staged: args.staged as boolean, + } + : undefined, + fingerprintPath: args.fingerprint as string | undefined, + config: { + dimensions, + changedLinesOnly: !(args.all as boolean), + }, + }); + + let output: string; + switch (args.format) { + case "json": + output = formatReviewJSON(report); + break; + case "github": { + const comments = formatGitHubPRComments(report); + const summary = formatReviewSummary(report); + output = JSON.stringify({ summary, comments }, null, 2); + break; + } + default: + output = formatReviewCLI(report); + } + + process.stdout.write(`${output}\n`); + process.exit(report.summary.errors > 0 ? 1 : 0); + } catch (err) { + console.error( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(2); + } + }, +}); diff --git a/packages/ghost-cli/src/target-resolver.ts b/packages/ghost-cli/src/target-resolver.ts new file mode 100644 index 0000000..a124594 --- /dev/null +++ b/packages/ghost-cli/src/target-resolver.ts @@ -0,0 +1,5 @@ +import type { Target } from "@ghost/core"; +import { resolveTarget } from "@ghost/core"; + +export type { Target }; +export { resolveTarget }; diff --git a/packages/ghost-core/package-lock.json b/packages/ghost-core/package-lock.json new file mode 100644 index 0000000..296abb7 --- /dev/null +++ b/packages/ghost-core/package-lock.json @@ -0,0 +1,1710 @@ +{ + "name": "@ghost/core", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@ghost/core", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.105", + "diff": "^7.0.0", + "jiti": "^2.4.0", + "pixelmatch": "^6.0.0", + "pngjs": "^7.0.0", + "postcss": "^8.5.0" + }, + "devDependencies": { + "@types/diff": "^6.0.0", + "@types/pngjs": "^6.0.0" + }, + "optionalDependencies": { + "playwright": "^1.50.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.2.105", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.105.tgz", + "integrity": "sha512-gPn7UEX6WrnIqCclNzfER6Of0mQ1ruxcNe0jrVXR9kOG09qFfaGzd6hy2qr3envCAB/dACkS7UDJoCm4/jg5Dg==", + "license": "SEE LICENSE IN README.md", + "dependencies": { + "@anthropic-ai/sdk": "^0.81.0", + "@modelcontextprotocol/sdk": "^1.29.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "^0.34.2", + "@img/sharp-darwin-x64": "^0.34.2", + "@img/sharp-linux-arm": "^0.34.2", + "@img/sharp-linux-arm64": "^0.34.2", + "@img/sharp-linux-x64": "^0.34.2", + "@img/sharp-linuxmusl-arm64": "^0.34.2", + "@img/sharp-linuxmusl-x64": "^0.34.2", + "@img/sharp-win32-arm64": "^0.34.2", + "@img/sharp-win32-x64": "^0.34.2" + }, + "peerDependencies": { + "zod": "^4.0.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.81.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.81.0.tgz", + "integrity": "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/diff": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/diff/-/diff-6.0.0.tgz", + "integrity": "sha512-dhVCYGv3ZSbzmQaBSagrv1WJ6rXCdkyTcDyoNu1MD8JohI7pR7k8wdZEm+mvdxRKXyHVwckFzWU1vJc+Z29MlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/pngjs": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz", + "integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.12", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", + "integrity": "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pixelmatch": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-6.0.0.tgz", + "integrity": "sha512-FYpL4XiIWakTnIqLqvt3uN4L9B3TsuHIvhLILzTiJZMJUsGvmKNeL4H3b6I99LRyerK9W4IuOXw+N28AtRgK2g==", + "license": "ISC", + "dependencies": { + "pngjs": "^7.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "license": "Apache-2.0", + "optional": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/packages/ghost-core/package.json b/packages/ghost-core/package.json index 09c759e..a507833 100644 --- a/packages/ghost-core/package.json +++ b/packages/ghost-core/package.json @@ -19,6 +19,7 @@ "build": "tsc --build" }, "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.105", "diff": "^7.0.0", "jiti": "^2.4.0", "pixelmatch": "^6.0.0", diff --git a/packages/ghost-core/src/agents/base.ts b/packages/ghost-core/src/agents/base.ts new file mode 100644 index 0000000..91ec12d --- /dev/null +++ b/packages/ghost-core/src/agents/base.ts @@ -0,0 +1,140 @@ +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 }, + }); + + if (state.status === "failed" && !state.result) { + const reasons = [...state.warnings, ...state.reasoning].filter(Boolean); + throw new Error( + `[${this.name}] Agent failed: ${reasons[0] ?? "unknown error"}`, + ); + } + + 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..d9a7b3e --- /dev/null +++ b/packages/ghost-core/src/agents/comparison.ts @@ -0,0 +1,59 @@ +import { compare } from "../stages/compare.js"; +import type { + AgentContext, + DesignFingerprint, + 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; +} + +/** + * @deprecated Use `compare()` from `stages/compare` instead. + * This class is kept for backward compatibility but delegates to the stage function. + */ +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> { + try { + const result = await compare(input); + state.result = result.data; + state.confidence = result.confidence; + state.warnings.push(...result.warnings); + state.reasoning.push(...result.reasoning); + state.status = "completed"; + } catch (err) { + state.warnings.push( + `Comparison failed: ${err instanceof Error ? err.message : String(err)}`, + ); + state.status = "failed"; + } + + return state; + } +} diff --git a/packages/ghost-core/src/agents/compliance.ts b/packages/ghost-core/src/agents/compliance.ts new file mode 100644 index 0000000..b02f19a --- /dev/null +++ b/packages/ghost-core/src/agents/compliance.ts @@ -0,0 +1,86 @@ +import { comply } from "../stages/comply.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; +} + +/** + * @deprecated Use `comply()` from `stages/comply` instead. + * This class is kept for backward compatibility but delegates to the stage function. + */ +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> { + try { + const result = await comply(input); + state.result = result.data; + state.confidence = result.confidence; + state.warnings.push(...result.warnings); + state.reasoning.push(...result.reasoning); + state.status = "completed"; + } catch (err) { + state.warnings.push( + `Compliance check failed: ${err instanceof Error ? err.message : String(err)}`, + ); + state.status = "failed"; + } + + return state; + } +} diff --git a/packages/ghost-core/src/agents/director.ts b/packages/ghost-core/src/agents/director.ts new file mode 100644 index 0000000..b74760e --- /dev/null +++ b/packages/ghost-core/src/agents/director.ts @@ -0,0 +1,187 @@ +import { compareFleet } from "../evolution/fleet.js"; +import { compare as compareStage } from "../stages/compare.js"; +import type { ComplianceInput, ComplianceReport } from "../stages/comply.js"; +import { comply as complyStage } from "../stages/comply.js"; +import { extract } from "../stages/extract.js"; +import type { + AgentContext, + AgentResult, + DesignFingerprint, + EnrichedComparison, + EnrichedFingerprint, + FleetComparison, + FleetMember, + SampledMaterial, + Target, +} from "../types.js"; +import type { DiscoveredSystem, DiscoveryInput } from "./discovery.js"; +import { DiscoveryAgent } from "./discovery.js"; +import { FingerprintAgent } from "./fingerprint.js"; + +/** + * Director — orchestrates the fingerprinting pipeline. + * + * Uses plain stage functions for deterministic steps (extract, compare, comply) + * and agents for LLM-powered steps (fingerprint, discovery). + */ +export class Director { + private discoveryAgent = new DiscoveryAgent(); + + /** + * Profile a target: extract → fingerprint + */ + async profile( + target: Target, + ctx: AgentContext, + ): Promise<{ + extraction: AgentResult; + fingerprint: AgentResult; + }> { + const extractionResult = await extract(target); + const extraction = stageToAgentResult(extractionResult); + + // Create a fresh agent per profile — FingerprintAgent has per-run state + // that would collide if two profiles run in parallel on the same instance. + const fingerprint = await new 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; + }> { + const [sourceResult, targetResult] = await Promise.all([ + this.profile(sourceTarget, ctx), + this.profile(targetTarget, ctx), + ]); + + const comparisonResult = await compareStage({ + source: sourceResult.fingerprint.data, + target: targetResult.fingerprint.data, + sourceLabel: sourceTarget.name ?? sourceTarget.value, + targetLabel: targetTarget.name ?? targetTarget.value, + }); + + return { + source: sourceResult.fingerprint, + target: targetResult.fingerprint, + comparison: stageToAgentResult(comparisonResult), + }; + } + + /** + * 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 comparisonResult = await compareStage({ + source: parentFingerprint, + target: fingerprint.data, + }); + + return { fingerprint, comparison: stageToAgentResult(comparisonResult) }; + } + + /** + * 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 complianceResult = await complyStage({ + ...input, + fingerprint: fingerprint.data, + }); + + return { + fingerprint, + compliance: stageToAgentResult(complianceResult), + }; + } + + /** + * 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; + }> { + const profileResults = await Promise.all( + targets.map(async (target) => { + const result = await this.profile(target, ctx); + return { target, fingerprint: result.fingerprint }; + }), + ); + + const fleetMembers: FleetMember[] = profileResults.map((r) => ({ + id: r.target.name ?? r.target.value, + fingerprint: r.fingerprint.data, + parentRef: r.target, + })); + + const fleetResult = compareFleet(fleetMembers, { + cluster: options?.cluster ?? true, + }); + + return { + members: profileResults, + fleet: fleetResult, + }; + } +} + +/** Convert a StageResult to an AgentResult for backward compatibility. */ +function stageToAgentResult( + stage: import("../stages/types.js").StageResult, +): AgentResult { + return { + ...stage, + iterations: 1, + }; +} diff --git a/packages/ghost-core/src/agents/discovery.ts b/packages/ghost-core/src/agents/discovery.ts new file mode 100644 index 0000000..0bbf6d1 --- /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..7e88258 --- /dev/null +++ b/packages/ghost-core/src/agents/extraction.ts @@ -0,0 +1,37 @@ +import { extract } from "../stages/extract.js"; +import type { AgentContext, SampledMaterial, Target } from "../types.js"; +import { BaseAgent } from "./base.js"; +import type { AgentState } from "./types.js"; + +/** + * @deprecated Use `extract()` from `stages/extract` instead. + * This class is kept for backward compatibility but delegates to the stage function. + */ +export class ExtractionAgent extends BaseAgent { + name = "extraction"; + maxIterations = 1; + systemPrompt = + "File extraction agent — walks and samples design-relevant files from any target."; + + protected async step( + state: AgentState, + input: Target, + _ctx: AgentContext, + ): Promise> { + try { + const result = await extract(input); + state.result = result.data; + state.confidence = result.confidence; + state.warnings.push(...result.warnings); + state.reasoning.push(...result.reasoning); + state.status = result.confidence > 0 ? "completed" : "failed"; + } catch (err) { + state.warnings.push( + `Extraction failed: ${err instanceof Error ? err.message : String(err)}`, + ); + state.status = "failed"; + } + + return state; + } +} diff --git a/packages/ghost-core/src/agents/fingerprint-agent.ts b/packages/ghost-core/src/agents/fingerprint-agent.ts new file mode 100644 index 0000000..923d76f --- /dev/null +++ b/packages/ghost-core/src/agents/fingerprint-agent.ts @@ -0,0 +1,148 @@ +/** + * Fingerprint Agent — powered by Claude Agent SDK. + * + * Instead of sampling files and stuffing them into a prompt, + * this gives the LLM filesystem tools and lets it explore the + * codebase itself to extract the visual language. + */ + +import { parseColorToOklch } from "../fingerprint/colors.js"; +import { computeSemanticEmbedding } from "../fingerprint/embed-api.js"; +import { computeEmbedding } from "../fingerprint/embedding.js"; +import { FINGERPRINT_SCHEMA } from "../llm/prompt.js"; +import type { + AgentContext, + AgentResult, + DesignFingerprint, + EnrichedFingerprint, + TargetType, +} from "../types.js"; + +const PROMPT = `You are producing a design fingerprint — a structured snapshot of how a project looks visually. + +Explore the codebase at the current directory to find where visual design values are defined. Read those definitions and report exactly what you find. + +## What a fingerprint captures + +1. **Palette** — the color values defined in this project. Find the actual hex values declared as variables, tokens, or constants. Report the neutral/gray scale, semantic roles (danger, success, surface, text, border), and which colors dominate. + +2. **Spacing** — the spacing scale in px. Find where spacing is defined as a system (variables, token sets, scales). + +3. **Typography** — font family declarations, the size scale in px, font weight usage, and line-height tendency (tight/normal/loose). + +4. **Surfaces** — border radius values in px (include pill values like 999), shadow complexity (none/subtle/layered), and border usage level. + +## Important + +- Read the actual value definitions. If a variable references another variable, follow the chain. +- Only report values you found in the source. Do not guess or fill in defaults. + +## Output + +Respond with ONLY a JSON object matching this schema: + +${FINGERPRINT_SCHEMA} + +Set "id" to "PROJECT_ID". +Set "source" to "llm". +Colors must be hex (e.g. #1a1a1a).`; + +export interface FingerprintAgentOptions { + targetDir: string; + targetType: TargetType; + projectId: string; + verbose?: boolean; + embedding?: AgentContext["embedding"]; +} + +export async function runFingerprintAgent( + options: FingerprintAgentOptions, +): Promise> { + const { query } = await import("@anthropic-ai/claude-agent-sdk"); + + const startTime = Date.now(); + const prompt = PROMPT.replace("PROJECT_ID", options.projectId); + const reasoning: string[] = []; + let resultText = ""; + + for await (const message of query({ + prompt, + options: { + allowedTools: ["Read", "Glob", "Grep"], + cwd: options.targetDir, + maxTurns: 60, + }, + })) { + // Log tool usage for verbose output + if ( + options.verbose && + message.type === "assistant" && + "message" in message + ) { + const msg = message.message as { + content?: Array<{ type: string; name?: string; text?: string }>; + }; + if (Array.isArray(msg?.content)) { + for (const block of msg.content) { + if (block.type === "tool_use" && block.name) { + reasoning.push(`Tool: ${block.name}`); + } + if (block.type === "text" && block.text?.trim()) { + reasoning.push(block.text.trim().slice(0, 200)); + } + } + } + } + + if (message.type === "result" && message.subtype === "success") { + resultText = message.result; + } + } + + if (!resultText) { + throw new Error("Agent did not produce a result"); + } + + // Parse fingerprint from result + const jsonMatch = resultText.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + throw new Error("Failed to extract JSON from agent result"); + } + + const fp: DesignFingerprint = JSON.parse(jsonMatch[0]); + fp.source = "llm"; + fp.timestamp = new Date().toISOString(); + + // Recompute oklch from hex values deterministically + recomputeOklch(fp); + + // Compute embedding + fp.embedding = options.embedding + ? await computeSemanticEmbedding(fp, options.embedding) + : computeEmbedding(fp); + + const enriched: EnrichedFingerprint = { + ...fp, + targetType: options.targetType, + }; + + return { + data: enriched, + confidence: 0.85, + warnings: [], + reasoning, + iterations: 1, + duration: Date.now() - startTime, + }; +} + +function recomputeOklch(fp: DesignFingerprint): void { + for (const color of fp.palette.dominant) { + const oklch = parseColorToOklch(color.value); + if (oklch) color.oklch = oklch; + } + for (const color of fp.palette.semantic) { + const oklch = parseColorToOklch(color.value); + if (oklch) color.oklch = oklch; + } +} diff --git a/packages/ghost-core/src/agents/fingerprint.ts b/packages/ghost-core/src/agents/fingerprint.ts new file mode 100644 index 0000000..4e77c19 --- /dev/null +++ b/packages/ghost-core/src/agents/fingerprint.ts @@ -0,0 +1,343 @@ +import { parseColorToOklch } from "../fingerprint/colors.js"; +import { computeSemanticEmbedding } from "../fingerprint/embed-api.js"; +import { computeEmbedding } from "../fingerprint/embedding.js"; +import { createProvider } from "../llm/index.js"; +import type { + AgentContext, + DesignFingerprint, + EnrichedFingerprint, + SampledMaterial, +} from "../types.js"; +import { BaseAgent } from "./base.js"; +import { + executeTool, + getToolDefinitions, + MAX_TOOL_CALLS, +} from "./tools/index.js"; +import type { ChatMessage, ToolContext } from "./tools/types.js"; +import type { AgentState } from "./types.js"; + +/** + * Fingerprint Agent — "What design language is this?" + * + * Extracts deterministic signals, then uses LLM with tool access + * for interpretation, validation, and gap-filling. + * + * Iteration model: + * 0: Extract signals → LLM interpret (with tools available) + * 1..N: Tool call/response cycles if LLM requests more data + * N+1: Validate + compute embedding + * N+2: Generate language profile + */ +export class FingerprintAgent extends BaseAgent< + SampledMaterial, + EnrichedFingerprint +> { + name = "fingerprint"; + maxIterations = 8; + systemPrompt = `You are a design fingerprinting agent. You analyze source files +from design systems and produce structured fingerprints capturing their visual +language: palette, spacing, typography, and surfaces.`; + + // State preserved across iterations for tool-use loop + private chatMessages: ChatMessage[] = []; + private toolCallCount = 0; + private toolCtx: ToolContext | null = null; + private pendingFingerprint: DesignFingerprint | null = null; + + protected async step( + state: AgentState, + input: SampledMaterial, + ctx: AgentContext, + ): Promise> { + if (state.iterations === 0) { + // Step 0: Extract signals and send to LLM + return this.initialInterpretation(state, input, ctx); + } + + // If we have a pending fingerprint, proceed to validation + if (this.pendingFingerprint) { + return this.validateAndFinalize(state, input, ctx); + } + + // Safety: if we've used too many iterations without a result, fall back to interpret() + if (state.iterations >= 5 && !this.pendingFingerprint && ctx.llm) { + state.reasoning.push( + "Tool use loop exhausted — falling back to single-shot interpret()", + ); + try { + const provider = createProvider(ctx.llm); + const projectId = input.metadata.packageJson?.name ?? "project"; + const fingerprint = await provider.interpret(input, projectId); + this.pendingFingerprint = fingerprint; + state.confidence = 0.7; + return state; // Next iteration will hit validateAndFinalize + } catch (err) { + state.warnings.push( + `Fallback interpret failed: ${err instanceof Error ? err.message : String(err)}`, + ); + state.status = "failed"; + return state; + } + } + + // Tool call/response cycle — only if LLM requested tool use + if ( + this.chatMessages.length > 0 && + this.hasPendingToolCalls(state) && + ctx.llm?.provider + ) { + return this.toolUseLoop(state, input, ctx); + } + + // Fallback: complete + state.status = "completed"; + return state; + } + + private async initialInterpretation( + state: AgentState, + input: SampledMaterial, + ctx: AgentContext, + ): Promise> { + if (!ctx.llm) { + state.warnings.push( + "No LLM configured. Ghost v2 requires an LLM API key for fingerprinting.", + ); + state.status = "failed"; + return state; + } + + // Reset per-run state + this.chatMessages = []; + this.toolCallCount = 0; + this.pendingFingerprint = null; + + try { + const provider = createProvider(ctx.llm); + const projectId = input.metadata.packageJson?.name ?? "project"; + + state.reasoning.push( + `Sending ${input.files.length} sampled files to LLM for extraction`, + ); + + const fingerprint = await provider.interpret(input, projectId); + this.pendingFingerprint = fingerprint; + state.confidence = 0.75; + + if (this.pendingFingerprint) { + state.reasoning.push( + `LLM produced fingerprint: ${this.pendingFingerprint.palette.dominant.length} dominant colors, ` + + `${this.pendingFingerprint.spacing.scale.length} spacing steps, ` + + `${this.pendingFingerprint.typography.families.length} font families`, + ); + } + } catch (err) { + state.warnings.push( + `LLM interpretation failed: ${err instanceof Error ? err.message : String(err)}`, + ); + state.status = "failed"; + } + + return state; + } + + private async toolUseLoop( + state: AgentState, + _input: SampledMaterial, + ctx: AgentContext, + ): Promise> { + if (!ctx.llm || !this.toolCtx) { + state.status = "completed"; + return state; + } + + try { + const provider = createProvider(ctx.llm); + if (!provider.chat) { + state.status = "completed"; + return state; + } + + // Execute pending tool calls from last assistant message + const lastMessage = this.chatMessages[this.chatMessages.length - 1]; + if (lastMessage?.tool_calls) { + for (const call of lastMessage.tool_calls) { + if (this.toolCallCount >= MAX_TOOL_CALLS) { + this.chatMessages.push({ + role: "tool", + content: + "Tool call budget exhausted. Please produce the fingerprint with available data.", + tool_call_id: call.id, + }); + continue; + } + + const result = await executeTool(call, this.toolCtx); + this.chatMessages.push({ + role: "tool", + content: result.content, + tool_call_id: call.id, + }); + this.toolCallCount++; + state.reasoning.push( + `Tool ${call.name}: ${result.content.slice(0, 100)}...`, + ); + } + } + + // Send tool results back to LLM + const response = await provider.chat( + this.chatMessages, + getToolDefinitions(), + ); + + if (response.tool_calls?.length && this.toolCallCount < MAX_TOOL_CALLS) { + // More tool calls requested + this.chatMessages.push({ + role: "assistant", + content: response.content ?? "", + tool_calls: response.tool_calls, + }); + state.reasoning.push( + `LLM requested ${response.tool_calls.length} more tool(s): ${response.tool_calls.map((tc) => tc.name).join(", ")}`, + ); + return state; + } + + // LLM returned content — parse as fingerprint + if (response.content) { + this.pendingFingerprint = this.parseFingerprint(response.content); + state.confidence = 0.8; + state.reasoning.push("LLM produced fingerprint after tool use"); + } + } catch (err) { + state.warnings.push( + `Tool use loop error: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + return state; + } + + private async validateAndFinalize( + state: AgentState, + input: SampledMaterial, + ctx: AgentContext, + ): Promise> { + if (!this.pendingFingerprint) { + state.status = "failed"; + state.warnings.push("No fingerprint to validate"); + return state; + } + + const fp = this.pendingFingerprint; + + // Recompute all oklch tuples from value strings using deterministic math. + // Don't trust the LLM's mental color space conversion. + recomputeOklch(fp); + + // Compute embedding + fp.embedding = ctx.embedding + ? await computeSemanticEmbedding(fp, ctx.embedding) + : computeEmbedding(fp); + + // Validate + const issues = this.validateOutput(fp); + if (issues.length > 0) { + state.reasoning.push( + `Validation: ${issues.length} issue(s): ${issues.join("; ")}`, + ); + // Self-healing could go here in the future + } else { + state.confidence = Math.min(state.confidence + 0.1, 0.95); + state.reasoning.push("Validation passed"); + } + + const enriched: EnrichedFingerprint = { + ...fp, + targetType: input.metadata.targetType, + }; + + state.result = enriched; + state.status = "completed"; + + // Clean up + this.pendingFingerprint = null; + this.chatMessages = []; + this.toolCtx = null; + + return state; + } + + private hasPendingToolCalls( + _state: AgentState, + ): boolean { + const last = this.chatMessages[this.chatMessages.length - 1]; + return !!last?.tool_calls?.length; + } + + private parseFingerprint(text: string): DesignFingerprint { + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + throw new Error("Failed to extract JSON from LLM response"); + } + const fingerprint: DesignFingerprint = JSON.parse(jsonMatch[0]); + fingerprint.source = "llm"; + fingerprint.timestamp = new Date().toISOString(); + return fingerprint; + } + + private validateOutput(fp: DesignFingerprint): string[] { + const issues: string[] = []; + + if (fp.palette.dominant.length === 0 && fp.palette.semantic.length === 0) { + issues.push("No colors detected — palette is empty"); + } + + if (fp.spacing.scale.length === 0) { + issues.push("No spacing scale detected"); + } + + if (fp.typography.families.length === 0) { + issues.push("No font families detected"); + } + + // Check for unreasonable values + const spacingMax = 500; + const radiusMax = 200; + + for (const s of fp.spacing.scale) { + if (s < 0 || s > spacingMax) { + issues.push(`Unreasonable spacing value: ${s}`); + break; + } + } + + for (const r of fp.surfaces.borderRadii) { + // 9999/999 are common "pill" values — allow them + if (r < 0 || (r > radiusMax && r !== 999 && r !== 9999)) { + issues.push(`Unreasonable border radius: ${r}`); + break; + } + } + + return issues; + } +} + +/** + * Recompute all oklch tuples from color value strings using deterministic math. + * The LLM is asked to provide color values but not to do color space conversion — + * we handle that precisely here. + */ +function recomputeOklch(fp: DesignFingerprint): void { + for (const color of fp.palette.dominant) { + const oklch = parseColorToOklch(color.value); + if (oklch) color.oklch = oklch; + } + for (const color of fp.palette.semantic) { + const oklch = parseColorToOklch(color.value); + if (oklch) color.oklch = oklch; + } +} diff --git a/packages/ghost-core/src/agents/index.ts b/packages/ghost-core/src/agents/index.ts new file mode 100644 index 0000000..f3b48d5 --- /dev/null +++ b/packages/ghost-core/src/agents/index.ts @@ -0,0 +1,17 @@ +export { BaseAgent } from "./base.js"; +export type { ComparisonInput } from "./comparison.js"; +export { ComparisonAgent } from "./comparison.js"; +export type { + ComplianceInput, + ComplianceReport, + ComplianceRule, + ComplianceThresholds, + ComplianceViolation, +} from "./compliance.js"; +export { ComplianceAgent } from "./compliance.js"; +export { Director } from "./director.js"; +export type { DiscoveredSystem, DiscoveryInput } from "./discovery.js"; +export { DiscoveryAgent } 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/tools/index.ts b/packages/ghost-core/src/agents/tools/index.ts new file mode 100644 index 0000000..6944a85 --- /dev/null +++ b/packages/ghost-core/src/agents/tools/index.ts @@ -0,0 +1,51 @@ +import { listFilesTool } from "./list-files.js"; +import { runExtractorTool } from "./run-extractor.js"; +import { searchFilesTool } from "./search-files.js"; +import type { + AgentTool, + ToolCall, + ToolContext, + ToolDefinition, + ToolResult, +} from "./types.js"; + +export type { + AgentTool, + ChatMessage, + ChatResponse, + ToolCall, + ToolContext, + ToolDefinition, + ToolResult, +} from "./types.js"; + +/** All available fingerprint agent tools. */ +export const FINGERPRINT_TOOLS: AgentTool[] = [ + searchFilesTool, + runExtractorTool, + listFilesTool, +]; + +/** Convert tools to LLM-provider-neutral definitions. */ +export function getToolDefinitions(): ToolDefinition[] { + return FINGERPRINT_TOOLS.map((tool) => ({ + name: tool.name, + description: tool.description, + input_schema: tool.parameters, + })); +} + +/** Dispatch a tool call and return the result. */ +export async function executeTool( + call: ToolCall, + ctx: ToolContext, +): Promise { + const tool = FINGERPRINT_TOOLS.find((t) => t.name === call.name); + if (!tool) { + return { content: `Unknown tool: ${call.name}` }; + } + return tool.execute(call.args, ctx); +} + +/** Maximum number of tool calls per fingerprint run. */ +export const MAX_TOOL_CALLS = 3; diff --git a/packages/ghost-core/src/agents/tools/list-files.ts b/packages/ghost-core/src/agents/tools/list-files.ts new file mode 100644 index 0000000..133b3d0 --- /dev/null +++ b/packages/ghost-core/src/agents/tools/list-files.ts @@ -0,0 +1,54 @@ +import { walkDirectory } from "../../extractors/walker.js"; +import type { AgentTool, ToolContext, ToolResult } from "./types.js"; + +/** + * list_files — list available files in the project directory. + * + * The LLM uses this to explore the project structure when the + * initial sample doesn't provide enough context. + */ +export const listFilesTool: AgentTool = { + name: "list_files", + description: + "List files in the project directory, optionally filtered by keyword. Shows file paths and types. Use to understand project structure before requesting specific files.", + parameters: { + type: "object", + properties: { + filter: { + type: "string", + description: + "Optional keyword to filter file paths (e.g., 'theme', 'color', 'token')", + }, + }, + required: [], + }, + + async execute( + args: Record, + ctx: ToolContext, + ): Promise { + const filter = args.filter ? String(args.filter).toLowerCase() : undefined; + + try { + const allFiles = await walkDirectory(ctx.sourceDir); + + const filtered = filter + ? allFiles.filter((f) => f.path.toLowerCase().includes(filter)) + : allFiles; + + const listing = filtered + .slice(0, 100) + .map((f) => `${f.path} [${f.type}]`) + .join("\n"); + + return { + content: `${filtered.length} file(s)${filter ? ` matching "${filter}"` : ""}:\n${listing}${filtered.length > 100 ? `\n... and ${filtered.length - 100} more` : ""}`, + metadata: { totalFiles: filtered.length }, + }; + } catch (err) { + return { + content: `Error listing files: ${err instanceof Error ? err.message : String(err)}`, + }; + } + }, +}; diff --git a/packages/ghost-core/src/agents/tools/run-extractor.ts b/packages/ghost-core/src/agents/tools/run-extractor.ts new file mode 100644 index 0000000..990b407 --- /dev/null +++ b/packages/ghost-core/src/agents/tools/run-extractor.ts @@ -0,0 +1,123 @@ +import { parseCSS } from "../../resolvers/css.js"; +import type { CSSToken } from "../../types.js"; +import type { AgentTool, ToolContext, ToolResult } from "./types.js"; + +/** + * run_extractor — run a deterministic signal extractor on a specific file. + * + * The LLM uses this when it wants structured token data from a file, + * rather than interpreting the raw source. + */ +export const runExtractorTool: AgentTool = { + name: "run_extractor", + description: + "Run a deterministic extractor on a sampled file to get structured token data. Use when you want parsed CSS custom properties, JSON tokens, or Swift token definitions rather than interpreting raw source.", + parameters: { + type: "object", + properties: { + file_path: { + type: "string", + description: + "Path of the file to extract from (relative, as shown in the sample)", + }, + extractor: { + type: "string", + description: "Which extractor to run", + enum: ["css", "json"], + }, + }, + required: ["file_path", "extractor"], + }, + + async execute( + args: Record, + ctx: ToolContext, + ): Promise { + const filePath = String(args.file_path ?? ""); + const extractor = String(args.extractor ?? ""); + + // Find the file in sampled material + const file = ctx.material.files.find((f) => f.path === filePath); + if (!file) { + return { + content: `File "${filePath}" not found in sampled material. Available files: ${ctx.material.files.map((f) => f.path).join(", ")}`, + }; + } + + try { + let tokens: CSSToken[] = []; + + switch (extractor) { + case "css": + tokens = parseCSS(file.content); + break; + case "json": + tokens = extractJSONTokens(file.content); + break; + default: + return { + content: `Unknown extractor: ${extractor}. Use "css" or "json".`, + }; + } + + if (tokens.length === 0) { + return { + content: `No tokens extracted from ${filePath} using ${extractor} extractor.`, + }; + } + + const summary = tokens + .slice(0, 50) + .map((t) => `${t.name}: ${t.resolvedValue ?? t.value} [${t.category}]`) + .join("\n"); + + return { + content: `Extracted ${tokens.length} tokens from ${filePath}:\n${summary}${tokens.length > 50 ? `\n... and ${tokens.length - 50} more` : ""}`, + metadata: { tokenCount: tokens.length }, + }; + } catch (err) { + return { + content: `Extraction error: ${err instanceof Error ? err.message : String(err)}`, + }; + } + }, +}; + +function extractJSONTokens(content: string): CSSToken[] { + try { + const json = JSON.parse(content); + return flattenTokenJSON(json, ""); + } catch { + return []; + } +} + +function flattenTokenJSON( + obj: Record, + prefix: string, +): CSSToken[] { + const tokens: CSSToken[] = []; + + for (const [key, val] of Object.entries(obj)) { + if (key.startsWith("$")) continue; + const path = prefix ? `${prefix}-${key}` : key; + + if (typeof val === "object" && val !== null) { + const record = val as Record; + if ("$value" in record || "value" in record) { + const value = String(record.$value ?? record.value); + tokens.push({ + name: `--${path}`, + value, + selector: ":root", + category: "other", + resolvedValue: value, + }); + } else { + tokens.push(...flattenTokenJSON(record, path)); + } + } + } + + return tokens; +} diff --git a/packages/ghost-core/src/agents/tools/search-files.ts b/packages/ghost-core/src/agents/tools/search-files.ts new file mode 100644 index 0000000..0e30596 --- /dev/null +++ b/packages/ghost-core/src/agents/tools/search-files.ts @@ -0,0 +1,88 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { walkDirectory } from "../../extractors/walker.js"; +import type { AgentTool, ToolContext, ToolResult } from "./types.js"; + +const MAX_RESULT_SIZE = 8000; + +/** + * search_files — search the project for files matching a pattern. + * + * The LLM uses this when it needs more design signal files + * that weren't included in the initial sample. + */ +export const searchFilesTool: AgentTool = { + name: "search_files", + description: + "Search the project directory for files matching a glob or keyword pattern. Returns file contents for matching files. Use when the initial sample is missing design tokens, theme files, or specific configuration.", + parameters: { + type: "object", + properties: { + pattern: { + type: "string", + description: + "Keyword or glob pattern to match file paths (e.g., 'spacing', 'tokens', '*.theme.ts', 'Color.swift')", + }, + reason: { + type: "string", + description: "Why you need these files (for logging)", + }, + }, + required: ["pattern"], + }, + + async execute( + args: Record, + ctx: ToolContext, + ): Promise { + const pattern = String(args.pattern ?? ""); + if (!pattern) { + return { content: "Error: pattern is required" }; + } + + try { + const allFiles = await walkDirectory(ctx.sourceDir); + const keyword = pattern.toLowerCase(); + + const matches = allFiles.filter((f) => + f.path.toLowerCase().includes(keyword), + ); + + if (matches.length === 0) { + return { + content: `No files matching "${pattern}" found.`, + metadata: { matchCount: 0 }, + }; + } + + // Read up to 5 matching files, respecting size budget + const results: string[] = []; + let totalSize = 0; + + for (const file of matches.slice(0, 5)) { + if (totalSize > MAX_RESULT_SIZE) break; + try { + const fullPath = join(ctx.sourceDir, file.path); + const content = await readFile(fullPath, "utf-8"); + const truncated = + content.length > 3000 + ? `${content.slice(0, 3000)}\n... (truncated)` + : content; + results.push(`--- ${file.path} ---\n${truncated}`); + totalSize += truncated.length; + } catch { + // Skip unreadable files + } + } + + return { + content: `Found ${matches.length} file(s) matching "${pattern}":\n\n${results.join("\n\n")}`, + metadata: { matchCount: matches.length }, + }; + } catch (err) { + return { + content: `Search error: ${err instanceof Error ? err.message : String(err)}`, + }; + } + }, +}; diff --git a/packages/ghost-core/src/agents/tools/types.ts b/packages/ghost-core/src/agents/tools/types.ts new file mode 100644 index 0000000..4b11acd --- /dev/null +++ b/packages/ghost-core/src/agents/tools/types.ts @@ -0,0 +1,63 @@ +import type { SampledMaterial } from "../../types.js"; + +/** + * A tool the fingerprint agent can invoke during analysis. + */ +export interface AgentTool { + name: string; + description: string; + parameters: ToolParameters; + execute(args: Record, ctx: ToolContext): Promise; +} + +export interface ToolParameters { + type: "object"; + properties: Record< + string, + { + type: string; + description: string; + enum?: string[]; + } + >; + required: string[]; +} + +export interface ToolContext { + /** The source directory on disk (for file access) */ + sourceDir: string; + /** The originally sampled material */ + material: SampledMaterial; +} + +export interface ToolResult { + content: string; + metadata?: Record; +} + +/** Tool definition in LLM-provider-neutral format (maps to both Anthropic and OpenAI). */ +export interface ToolDefinition { + name: string; + description: string; + input_schema: ToolParameters; +} + +/** A chat message in multi-turn tool-use conversation. */ +export interface ChatMessage { + role: "user" | "assistant" | "tool"; + content: string; + tool_call_id?: string; + tool_calls?: ToolCall[]; +} + +export interface ToolCall { + id: string; + name: string; + args: Record; +} + +export interface ChatResponse { + content?: string; + tool_calls?: ToolCall[]; + stop_reason?: string; +} diff --git a/packages/ghost-core/src/agents/types.ts b/packages/ghost-core/src/agents/types.ts new file mode 100644 index 0000000..ccaa538 --- /dev/null +++ b/packages/ghost-core/src/agents/types.ts @@ -0,0 +1,20 @@ +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..54a6fcd 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,82 @@ export function defineConfig(config: GhostConfig): GhostConfig { return config; } +/** + * Resolve a target string into a typed Target. + * + * Explicit prefixes (recommended): + * github:owner/repo → GitHub clone + * npm:package-name → npm pack + * figma:file-url → Figma API + * + * Unambiguous patterns (no prefix needed): + * /absolute/path → local path + * ./relative/path → local path + * ../parent/path → local path + * https://... → URL + * + * Ambiguous inputs without a prefix will throw an error + * with a suggestion to use a prefix. + */ +export function resolveTarget(input: string): Target { + // Explicit prefixes — unambiguous, preferred + const prefixMatch = input.match(/^(github|npm|figma|path|url):(.+)$/); + if (prefixMatch) { + const [, prefix, value] = prefixMatch; + return { type: prefix as Target["type"], value }; + } + + // Unambiguous: absolute or relative paths + if ( + input.startsWith("/") || + input.startsWith("./") || + input.startsWith("../") + ) { + return { type: "path", value: input }; + } + + // Unambiguous: exists as local path + if (existsSync(resolve(process.cwd(), input))) { + return { type: "path", value: input }; + } + + // Unambiguous: URLs + if (input.startsWith("http://") || input.startsWith("https://")) { + if (input.includes("figma.com")) { + return { type: "figma", value: input }; + } + return { type: "url", value: input }; + } + + // Unambiguous: npm scoped packages (@scope/name) + if (input.startsWith("@") && input.includes("/")) { + return { type: "npm", value: input }; + } + + // Ambiguous — require a prefix + const suggestions: string[] = []; + if (input.includes("/")) { + suggestions.push(` github:${input} (GitHub repo)`); + suggestions.push(` path:${input} (local path)`); + } else { + suggestions.push(` npm:${input} (npm package)`); + suggestions.push(` github:owner/${input} (GitHub repo)`); + } + + throw new Error( + `Ambiguous target "${input}". Use an explicit prefix:\n${suggestions.join("\n")}`, + ); +} + 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 +112,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 +137,38 @@ function mergeDefaults(raw: GhostConfig): GhostConfig { llm: raw.llm, embedding: raw.embedding, extractors: raw.extractors, + agents: raw.agents, + review: raw.review, }; } +/** + * 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..ce3dc84 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,18 @@ 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/fleet.ts b/packages/ghost-core/src/evolution/fleet.ts index 95bcfe6..417f824 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,123 @@ 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 clusterMembers(members: FleetMember[]): FleetCluster[] { +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[], maxK?: number): FleetCluster[] { if (members.length < 3) { return [ { @@ -113,61 +234,62 @@ function clusterMembers(members: FleetMember[]): FleetCluster[] { ]; } - // Find the two most distant members as initial centroids - let maxDist = -1; - let seedA = 0; - let seedB = 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[] = []; + 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++) { - const distToA = embeddingDistance( - members[i].fingerprint.embedding, - members[seedA].fingerprint.embedding, - ); - const distToB = embeddingDistance( - members[i].fingerprint.embedding, - members[seedB].fingerprint.embedding, - ); + // Run k-means for K=1 through kMax, collect WCSS + const results: { + k: number; + wcss: number; + assignments: number[]; + centroids: number[][]; + }[] = []; - 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..5de5441 100644 --- a/packages/ghost-core/src/evolution/index.ts +++ b/packages/ghost-core/src/evolution/index.ts @@ -1,7 +1,9 @@ export { emitFingerprint } from "./emit.js"; +export type { FleetClusterOptions } from "./fleet.js"; export { compareFleet } from "./fleet.js"; export { appendHistory, readHistory, readRecentHistory } from "./history.js"; export { normalizeParentSource, resolveParent } from "./parent.js"; +export type { CheckBoundsOptions } from "./sync.js"; export { acknowledge, checkBounds, 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 a802571..ea06770 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,10 +52,11 @@ export async function writeSyncManifest( export async function acknowledge(opts: { child: DesignFingerprint; parent: DesignFingerprint; - parentRef: ParentSource; + parentRef: Target; 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/evolution/vector.ts b/packages/ghost-core/src/evolution/vector.ts index 0e64d35..769859a 100644 --- a/packages/ghost-core/src/evolution/vector.ts +++ b/packages/ghost-core/src/evolution/vector.ts @@ -9,7 +9,6 @@ export const DIMENSION_RANGES: Record = { spacing: [21, 31], typography: [31, 41], surfaces: [41, 49], - architecture: [49, 64], }; /** diff --git a/packages/ghost-core/src/extractors/index.ts b/packages/ghost-core/src/extractors/index.ts index b7a852a..6e4173f 100644 --- a/packages/ghost-core/src/extractors/index.ts +++ b/packages/ghost-core/src/extractors/index.ts @@ -2,8 +2,14 @@ import type { ExtractedMaterial, Extractor, ExtractorOptions, + SampledMaterial, + Target, } from "../types.js"; import { cssExtractor } from "./css.js"; +import { sampleDirectory } from "./sampler.js"; +import { materializeGithub } from "./sources/github.js"; +import { materializeNpm } from "./sources/npm.js"; +import { materializeUrl } from "./sources/url.js"; import { tailwindExtractor } from "./tailwind.js"; // Ordered by specificity — more specific extractors first @@ -19,6 +25,10 @@ export async function detectExtractors(cwd: string): Promise { return matched; } +/** + * Extract design material from a local directory. + * Legacy API — used by the old profile() path. + */ export async function extract( cwd: string, options?: ExtractorOptions & { extractorNames?: string[] }, @@ -43,9 +53,45 @@ export async function extract( } } - // Use the most specific extractor (first match) return extractors[0].extract(cwd, options); } +/** + * Sample design-relevant files from any target for LLM interpretation. + * This is the v2 extraction pipeline — walk + sample, no parsing. + */ +export async function extractFromTarget( + target: Target, + options?: ExtractorOptions, +): Promise { + const localDir = await materializeTarget(target); + return sampleDirectory(localDir, target.type, { + maxFiles: options?.maxFiles ?? 200, + ignore: options?.ignore, + }); +} + +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 not yet implemented"); + case "doc-site": + return materializeUrl(target.value); + default: + throw new Error(`Unsupported target type: ${target.type}`); + } +} + export { cssExtractor } from "./css.js"; +export { sampleDirectory } from "./sampler.js"; export { tailwindExtractor } from "./tailwind.js"; +export { walkAndCategorize, walkDirectory } from "./walker.js"; diff --git a/packages/ghost-core/src/extractors/sampler.ts b/packages/ghost-core/src/extractors/sampler.ts new file mode 100644 index 0000000..bb06920 --- /dev/null +++ b/packages/ghost-core/src/extractors/sampler.ts @@ -0,0 +1,334 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { ExtractedFile, SampledMaterial, TargetType } from "../types.js"; +import { walkDirectory } from "./walker.js"; + +const MAX_FILE_SIZE = 3000; +const MAX_FILE_SIZE_HIGH_PRIORITY = 20_000; // Theme/token files get more space +const MAX_TOTAL_CHARS = 120_000; // ~30K tokens +const MAX_COMPONENT_SAMPLES = 5; + +interface ScoredFile { + file: ExtractedFile; + score: number; + reason: string; +} + +/** + * Smart file sampler. Walks a directory, scores files by design-signal density, + * and returns the most informative files for LLM interpretation. + * + * This replaces format-detector + normalizer — the LLM figures out what + * the files contain. We just need to give it the right files. + */ +export async function sampleDirectory( + dir: string, + targetType: TargetType, + options?: { maxFiles?: number; ignore?: string[] }, +): Promise { + // Walk all files + const allFiles = await walkDirectory(dir, { + maxFiles: options?.maxFiles ?? 200, + ignore: options?.ignore, + }); + + // Read package.json if present + let packageJson: SampledMaterial["metadata"]["packageJson"]; + try { + const pkgPath = join(dir, "package.json"); + if (existsSync(pkgPath)) { + const raw = await readFile(pkgPath, "utf-8"); + const pkg = JSON.parse(raw); + packageJson = { + name: pkg.name, + dependencies: pkg.dependencies, + devDependencies: pkg.devDependencies, + }; + } + } catch { + // Skip if can't read + } + + // Read Package.swift if present (Swift Package Manager) + let packageSwift: SampledMaterial["metadata"]["packageSwift"]; + try { + const swiftPkgPath = join(dir, "Package.swift"); + if (existsSync(swiftPkgPath)) { + const raw = await readFile(swiftPkgPath, "utf-8"); + const nameMatch = raw.match(/name:\s*"([^"]+)"/); + const depMatches = [...raw.matchAll(/\.package\(\s*url:\s*"([^"]+)"/g)]; + packageSwift = { + name: nameMatch?.[1], + dependencies: depMatches.map((m) => m[1]), + }; + } + } catch { + // Skip if can't read + } + + // Score each file + const scored: ScoredFile[] = allFiles.map((file) => { + const { score, reason } = scoreFile(file); + return { file, score, reason }; + }); + + // Sort by score descending + scored.sort((a, b) => b.score - a.score); + + // Select files within budget + const selected: ScoredFile[] = []; + let totalChars = 0; + let componentCount = 0; + + for (const item of scored) { + if (item.score <= 0) continue; + + // Cap component files + if (item.file.type === "component") { + if (componentCount >= MAX_COMPONENT_SAMPLES) continue; + componentCount++; + } + + // High-priority files (score >= 8: theme, token, config) get more space + const fileLimit = + item.score >= 8 ? MAX_FILE_SIZE_HIGH_PRIORITY : MAX_FILE_SIZE; + const content = truncateFile(item.file.content, fileLimit); + if (totalChars + content.length > MAX_TOTAL_CHARS) { + // Try to fit with aggressive truncation + const remaining = MAX_TOTAL_CHARS - totalChars; + if (remaining < 500) break; + selected.push({ + ...item, + file: { ...item.file, content: item.file.content.slice(0, remaining) }, + }); + totalChars += remaining; + break; + } + + selected.push({ ...item, file: { ...item.file, content } }); + totalChars += content.length; + } + + // Always include package.json summary if available + if (packageJson) { + const pkgSummary = { + path: "package.json", + content: JSON.stringify( + { + name: packageJson.name, + dependencies: packageJson.dependencies, + devDependencies: packageJson.devDependencies, + }, + null, + 2, + ), + reason: "Package manifest — dependency detection", + }; + + // Prepend if not already selected + if (!selected.some((s) => s.file.path === "package.json")) { + selected.unshift({ + file: { + path: pkgSummary.path, + content: pkgSummary.content, + type: "config", + }, + score: 7, + reason: pkgSummary.reason, + }); + } + } + + // Always include Package.swift summary if available + if (packageSwift) { + const swiftPkgPath = join(dir, "Package.swift"); + if (!selected.some((s) => s.file.path === "Package.swift")) { + try { + const raw = await readFile(swiftPkgPath, "utf-8"); + selected.unshift({ + file: { + path: "Package.swift", + content: truncateFile(raw), + type: "config", + }, + score: 7, + reason: "Swift package manifest — dependency detection", + }); + } catch { + // Skip + } + } + } + + return { + files: selected.map((s) => ({ + path: s.file.path, + content: s.file.content, + reason: s.reason, + })), + metadata: { + totalFiles: allFiles.length, + sampledFiles: selected.length, + targetType, + packageJson, + packageSwift, + }, + }; +} + +/** + * Score a file by how likely it is to contain design system signals. + */ +function scoreFile(file: ExtractedFile): { score: number; reason: string } { + const name = file.path.toLowerCase(); + const baseName = name.split("/").pop() ?? ""; + + // Theme/token files — highest priority (web + native naming conventions) + if ( + /theme|tokens?|variables|design-tokens|primitives|colorscheme|designsystem|styleguide|styles-main/i.test( + baseName, + ) + ) { + return { score: 10, reason: "Theme/token definition file" }; + } + + // shadcn registry style files — contain embedded CSS with full token definitions + if (file.type === "config" && file.content.includes('"registry:style"')) { + return { + score: 10, + reason: "Registry style file with embedded CSS tokens", + }; + } + + // Asset catalog color definitions + if (file.type === "xcassets") { + if (/color-space|components/.test(file.content)) { + return { score: 9, reason: "Asset catalog color definition" }; + } + return { score: 5, reason: "Asset catalog file" }; + } + + // Swift files with theming infrastructure + if (file.type === "swift") { + if (/@Environment|ViewModifier|extension\s+Color/i.test(file.content)) { + return { + score: 8, + reason: "Swift theming infrastructure (Environment/ViewModifier)", + }; + } + if ( + /colors?|palette|spacing|typography|theme|tokens?|font|style/i.test( + baseName, + ) + ) { + return { score: 7, reason: "Swift file with design-related name" }; + } + if ( + /static\s+(?:let|var)\s+\w+.*(?:Color|CGFloat|Font|UIFont)/i.test( + file.content, + ) + ) { + return { score: 5, reason: "Swift file with design token definitions" }; + } + return { score: 3, reason: "Swift component file" }; + } + + // xcconfig files + if (file.type === "xcconfig") { + return { score: 3, reason: "Xcode build configuration" }; + } + + // CSS/SCSS with custom properties + if (file.type === "css" || file.type === "scss") { + const hasCustomProps = /--[\w-]+\s*:/.test(file.content); + const hasTailwind = /@tailwind|@theme|@apply/.test(file.content); + + if (hasCustomProps && hasTailwind) { + return { + score: 9, + reason: "CSS with custom properties + Tailwind directives", + }; + } + if (hasCustomProps) { + return { score: 8, reason: "CSS with custom properties" }; + } + if (hasTailwind) { + return { score: 8, reason: "CSS with Tailwind directives" }; + } + // Plain CSS still has some signal + return { score: 4, reason: "Style file" }; + } + + // Tailwind config + if (file.type === "tailwind-config") { + return { score: 8, reason: "Tailwind configuration" }; + } + + // Style Dictionary / W3C token files + if ( + file.type === "style-dictionary" || + file.type === "w3c-tokens" || + file.type === "json-tokens" + ) { + if (/\$value|\$type|"value"|"type"/.test(file.content)) { + return { score: 7, reason: "Design token file (JSON)" }; + } + } + + // Files in styles/tokens directories + if (/\/(styles?|tokens?|design|foundations?)\//i.test(name)) { + return { score: 6, reason: "File in styles/tokens directory" }; + } + + // Config files that might have theme info + if (file.type === "config") { + if (/theme|style|design|color|palette/i.test(baseName)) { + return { score: 6, reason: "Design-related config file" }; + } + return { score: 2, reason: "Config file" }; + } + + // Component files — sample a few for style signal + if (file.type === "component") { + return { score: 3, reason: "Component file" }; + } + + // SCSS files (even without custom properties — may have $variables) + if (baseName.endsWith(".scss") || baseName.endsWith(".less")) { + return { score: 5, reason: "SCSS/Less file (may contain variables)" }; + } + + // TS/JS files that look like they contain design values + if (baseName.endsWith(".ts") || baseName.endsWith(".js")) { + if ( + /colors?|palette|spacing|typography|theme|tokens?|font/i.test(baseName) + ) { + return { score: 7, reason: "JS/TS file with design-related name" }; + } + // Check content for theme-like objects + if ( + /(?:colors|palette|spacing|typography|theme)\s*[:=]/.test(file.content) + ) { + return { score: 5, reason: "JS/TS file with design-like exports" }; + } + } + + return { score: 0, reason: "Not relevant" }; +} + +/** + * Truncate a file to fit within context budget. + * For small files, return as-is. For large files, take the start + end. + */ +function truncateFile(content: string, limit: number = MAX_FILE_SIZE): string { + if (content.length <= limit) return content; + + const headSize = Math.floor(limit * 0.8); + const tailSize = limit - headSize - 20; // 20 chars for separator + + const head = content.slice(0, headSize); + const tail = content.slice(-tailSize); + + return `${head}\n\n/* ... truncated ... */\n\n${tail}`; +} 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..54bce2c --- /dev/null +++ b/packages/ghost-core/src/extractors/sources/github.ts @@ -0,0 +1,34 @@ +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..2fceb43 --- /dev/null +++ b/packages/ghost-core/src/extractors/sources/npm.ts @@ -0,0 +1,46 @@ +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 + // Use public registry explicitly to avoid corporate proxy issues + execSync( + `npm pack ${packageName} --pack-destination "${tempDir}" --registry https://registry.npmjs.org`, + { + stdio: "pipe", + timeout: 120000, + }, + ); + + // 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..0ece8a4 --- /dev/null +++ b/packages/ghost-core/src/extractors/sources/url.ts @@ -0,0 +1,116 @@ +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