From d922cddb2a15b4444632308bd1f50efbb56ab605 Mon Sep 17 00:00:00 2001 From: menukfernando Date: Fri, 13 Mar 2026 10:16:54 +0530 Subject: [PATCH] feat: add `openspec generate` command for AI-powered codebase analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `generate` command that performs deep codebase analysis — scanning package.json, config files, directory structure, and actual source code patterns — then outputs a structured context document. AI agents (Claude Code, Cursor, etc.) read this output and use it to write rich, project-specific module files. Scanner architecture: - 10 parallel detectors (language, framework, build-tool, testing, linting, styling, structure, package-manager, database, cicd) - Source file sampler that reads representative files and detects code patterns - Formatter with markdown (default) and JSON output modes Includes 38 new tests (68 total) covering detectors, sampler, formatter, and integration scenarios. README updated with docs and examples. Co-Authored-By: Claude Opus 4.6 --- README.md | 76 +++++- src/cli.ts | 12 + src/commands/generate.ts | 71 +++++ src/scanner/context.ts | 42 +++ src/scanner/detectors/build-tool.ts | 44 ++++ src/scanner/detectors/cicd.ts | 26 ++ src/scanner/detectors/database.ts | 39 +++ src/scanner/detectors/framework.ts | 115 +++++++++ src/scanner/detectors/index.ts | 68 +++++ src/scanner/detectors/language.ts | 53 ++++ src/scanner/detectors/linting.ts | 35 +++ src/scanner/detectors/package-manager.ts | 17 ++ src/scanner/detectors/structure.ts | 54 ++++ src/scanner/detectors/styling.ts | 29 +++ src/scanner/detectors/testing.ts | 54 ++++ src/scanner/formatter.ts | 137 ++++++++++ src/scanner/sampler.ts | 197 ++++++++++++++ src/scanner/types.ts | 66 +++++ tests/commands/generate.test.ts | 164 ++++++++++++ tests/scanner/detectors.test.ts | 315 +++++++++++++++++++++++ tests/scanner/formatter.test.ts | 114 ++++++++ tests/scanner/sampler.test.ts | 142 ++++++++++ 22 files changed, 1864 insertions(+), 6 deletions(-) create mode 100644 src/commands/generate.ts create mode 100644 src/scanner/context.ts create mode 100644 src/scanner/detectors/build-tool.ts create mode 100644 src/scanner/detectors/cicd.ts create mode 100644 src/scanner/detectors/database.ts create mode 100644 src/scanner/detectors/framework.ts create mode 100644 src/scanner/detectors/index.ts create mode 100644 src/scanner/detectors/language.ts create mode 100644 src/scanner/detectors/linting.ts create mode 100644 src/scanner/detectors/package-manager.ts create mode 100644 src/scanner/detectors/structure.ts create mode 100644 src/scanner/detectors/styling.ts create mode 100644 src/scanner/detectors/testing.ts create mode 100644 src/scanner/formatter.ts create mode 100644 src/scanner/sampler.ts create mode 100644 src/scanner/types.ts create mode 100644 tests/commands/generate.test.ts create mode 100644 tests/scanner/detectors.test.ts create mode 100644 tests/scanner/formatter.test.ts create mode 100644 tests/scanner/sampler.test.ts diff --git a/README.md b/README.md index a9b119c..9fc4f3b 100644 --- a/README.md +++ b/README.md @@ -69,13 +69,16 @@ When you update a convention — say, _"always use parameterized queries"_ — y ```bash # Install -npm install -g openspec +npm install -g @menukfernando/openspec # Initialize in your project cd your-project -openspec init +npx @menukfernando/openspec init -# Edit your rules +# Option A: Let AI analyze your codebase and write rules for you +openspec generate # outputs codebase analysis for your AI agent + +# Option B: Manually edit your rules # (customize the modules in .openspec/modules/) # Generate all AI context files @@ -195,6 +198,7 @@ Usage: openspec [command] [options] Commands: init Scaffold .openspec/ with config + example modules + generate Analyze codebase and output context for AI-powered rule generation sync [--quiet] Compile modules → generate all AI context files watch Watch for module changes, auto-sync on save status Show modules, targets, and sync status @@ -219,6 +223,54 @@ Creates the `.openspec/` directory with a config file and four example modules: testing.md ← Testing standards ``` +### `openspec generate` + +Performs deep codebase analysis — reading package.json, config files, directory structure, and actual source code — then outputs a structured context document. When run inside an AI agent (Claude Code, Cursor, etc.), the agent reads this output and uses it to write rich, project-specific module files. + +``` +$ openspec generate + +Analyzing codebase... +# Codebase Analysis — my-app + +## Tech Stack +- **Languages**: TypeScript (42 files) +- **Frontend**: React ^18.2.0 +- **Backend**: Express ^4.18.0 +- **Build**: Vite (vite.config.ts) +- **Testing**: Vitest (vitest.config.ts) +- **Styling**: Tailwind CSS +- **Database**: Prisma +... + +## Code Samples +### Component Example (src/components/UserCard.tsx) +... + +## Instructions for AI Agent +Using the analysis above, generate the following OpenSpec module files... +``` + +Options: + +```bash +openspec generate # Markdown output to stdout (default) +openspec generate --json # JSON output for programmatic use +openspec generate -o report.md # Write analysis to file +openspec generate -q # Suppress non-essential output +``` + +**Typical workflow with an AI agent:** + +```bash +# In Claude Code, Cursor, etc.: +# "Run openspec generate and fill in my rules" + +openspec generate # AI reads the output +# → AI writes .openspec/modules/shared.md, frontend.md, backend.md, testing.md +openspec sync # Generate all 7 AI context files +``` + ### `openspec sync` Reads all modules, filters per target, renders, and writes output files: @@ -460,8 +512,19 @@ openspec/ │ ├── types.ts # TypeScript types & defaults │ ├── commands/ │ │ ├── init.ts # 'openspec init' scaffolding +│ │ ├── generate.ts # 'openspec generate' handler │ │ ├── sync.ts # 'openspec sync' handler │ │ └── status.ts # 'openspec status' handler +│ ├── scanner/ +│ │ ├── types.ts # ScanResult interfaces +│ │ ├── context.ts # Builds DetectorContext +│ │ ├── sampler.ts # Source file sampling +│ │ ├── formatter.ts # Markdown/JSON output formatting +│ │ └── detectors/ # Stack detection modules +│ │ ├── index.ts # Orchestrator +│ │ ├── language.ts # TS/JS/Python/Go/Rust +│ │ ├── framework.ts # React/Next/Express/etc +│ │ └── ... # build-tool, testing, linting, etc. │ └── targets/ │ └── index.ts # Per-target renderers ├── assets/ @@ -513,9 +576,10 @@ npm run build # Compile TypeScript - [x] `init` / `sync` / `watch` / `status` / `clean` / `diff` / `add` commands - [x] `openspec diff` — preview changes before syncing - [x] `openspec add ` — scaffold new modules from CLI +- [x] `openspec generate` — AI-powered codebase analysis + rule generation - [x] CI pipeline (GitHub Actions — Linux/macOS/Windows, Node 18/20/22) -- [x] Test suite (vitest, 28 tests) -- [ ] `npx openspec` — zero-install usage (publish to npm) +- [x] Test suite (vitest, 68 tests) +- [ ] `npx @menukfernando/openspec` — zero-install usage (publish to npm) - [ ] MCP server mode for dynamic context - [ ] Module inheritance & composition - [ ] Template variable interpolation @@ -533,5 +597,5 @@ MIT — see [LICENSE](LICENSE) for details.

Stop copy-pasting AI rules.
- npx openspec init && npx openspec sync + npx @menukfernando/openspec init && npx @menukfernando/openspec sync

diff --git a/src/cli.ts b/src/cli.ts index 23c44d4..1b02d42 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,6 +8,7 @@ import { runSync } from "./commands/sync.js"; import { runStatus } from "./commands/status.js"; import { runDiff } from "./commands/diff.js"; import { runAdd } from "./commands/add.js"; +import { runGenerate } from "./commands/generate.js"; import { loadConfig } from "./config.js"; import { startWatcher } from "./watcher.js"; import { installHook, removeHook } from "./hooks.js"; @@ -102,6 +103,17 @@ program await runAdd(root, name, options); }); +program + .command("generate") + .description("Analyze codebase and output context for AI-powered rule generation") + .option("--json", "Output as JSON instead of markdown") + .option("-o, --output ", "Write analysis to file instead of stdout") + .option("-q, --quiet", "Suppress non-essential output") + .action(async (options) => { + const root = resolve("."); + await runGenerate(root, options); + }); + program .command("clean") .description("Remove all generated AI context files") diff --git a/src/commands/generate.ts b/src/commands/generate.ts new file mode 100644 index 0000000..9f34150 --- /dev/null +++ b/src/commands/generate.ts @@ -0,0 +1,71 @@ +import { existsSync } from "fs"; +import { writeFile, mkdir } from "fs/promises"; +import { join, dirname } from "path"; +import chalk from "chalk"; +import { buildContext } from "../scanner/context.js"; +import { runDetectors } from "../scanner/detectors/index.js"; +import { sampleSourceFiles } from "../scanner/sampler.js"; +import { formatMarkdown, formatJson } from "../scanner/formatter.js"; + +interface GenerateOptions { + json?: boolean; + output?: string; + quiet?: boolean; +} + +export async function runGenerate( + root: string, + options: GenerateOptions +): Promise { + const openspecDir = join(root, ".openspec"); + + // Auto-initialize if .openspec/ doesn't exist + if (!existsSync(openspecDir)) { + if (!options.quiet) { + console.log( + chalk.yellow("No .openspec/ directory found. Run `openspec init` first.") + ); + } + process.exitCode = 1; + return; + } + + if (!options.quiet) { + console.log(chalk.blue("Analyzing codebase...")); + } + + // Build context + const ctx = await buildContext(root); + + // Run all detectors + const scanResult = await runDetectors(ctx); + + // Sample source files + const codeSamples = await sampleSourceFiles(root, scanResult); + scanResult.codeSamples = codeSamples; + + // Format output + const output = options.json + ? formatJson(scanResult) + : formatMarkdown(scanResult); + + // Output + if (options.output) { + const outPath = join(root, options.output); + await mkdir(dirname(outPath), { recursive: true }); + await writeFile(outPath, output, "utf-8"); + if (!options.quiet) { + console.log(chalk.green(`Analysis written to ${options.output}`)); + } + } else { + console.log(output); + } + + if (!options.quiet && !options.output) { + console.log( + chalk.dim( + "\nAI agent: use the analysis above to write module files in .openspec/modules/, then run 'openspec sync'" + ) + ); + } +} diff --git a/src/scanner/context.ts b/src/scanner/context.ts new file mode 100644 index 0000000..d1a1b87 --- /dev/null +++ b/src/scanner/context.ts @@ -0,0 +1,42 @@ +import { readFile } from "fs/promises"; +import { join } from "path"; +import fg from "fast-glob"; +import type { DetectorContext } from "./types.js"; + +export async function buildContext(root: string): Promise { + const packageJson = await loadPackageJson(root); + const fileTree = await buildFileTree(root); + + return { root, packageJson, fileTree }; +} + +async function loadPackageJson( + root: string +): Promise | null> { + try { + const raw = await readFile(join(root, "package.json"), "utf-8"); + return JSON.parse(raw); + } catch { + return null; + } +} + +async function buildFileTree(root: string): Promise { + const entries = await fg(["**/*"], { + cwd: root, + deep: 2, + onlyFiles: false, + markDirectories: true, + dot: true, + ignore: [ + "node_modules/**", + ".git/**", + "dist/**", + "build/**", + ".next/**", + "coverage/**", + ".openspec/**", + ], + }); + return entries.sort(); +} diff --git a/src/scanner/detectors/build-tool.ts b/src/scanner/detectors/build-tool.ts new file mode 100644 index 0000000..fb876d3 --- /dev/null +++ b/src/scanner/detectors/build-tool.ts @@ -0,0 +1,44 @@ +import type { Detector, BuildToolInfo } from "../types.js"; + +export const detectBuildTools: Detector = async (ctx) => { + const buildTools: BuildToolInfo[] = []; + const deps = { ...ctx.packageJson?.dependencies, ...ctx.packageJson?.devDependencies }; + + if (deps?.["vite"]) { + const config = ctx.fileTree.find((f) => + /^vite\.config\.(ts|js|mjs)$/.test(f) + ); + buildTools.push({ name: "Vite", config }); + } + + if (deps?.["webpack"] || ctx.fileTree.some((f) => f.startsWith("webpack.config"))) { + const config = ctx.fileTree.find((f) => f.startsWith("webpack.config")); + buildTools.push({ name: "Webpack", config }); + } + + if (deps?.["esbuild"]) { + buildTools.push({ name: "esbuild" }); + } + + if (deps?.["rollup"]) { + const config = ctx.fileTree.find((f) => f.startsWith("rollup.config")); + buildTools.push({ name: "Rollup", config }); + } + + if (deps?.["turbo"] || ctx.fileTree.some((f) => f === "turbo.json")) { + buildTools.push({ name: "Turborepo", config: "turbo.json" }); + } + + // TypeScript compiler + if (ctx.fileTree.some((f) => f === "tsconfig.json")) { + const scripts = ctx.packageJson?.scripts || {}; + const useTsc = Object.values(scripts).some( + (s) => typeof s === "string" && s.includes("tsc") + ); + if (useTsc) { + buildTools.push({ name: "tsc", config: "tsconfig.json" }); + } + } + + return { buildTools }; +}; diff --git a/src/scanner/detectors/cicd.ts b/src/scanner/detectors/cicd.ts new file mode 100644 index 0000000..d8d54f1 --- /dev/null +++ b/src/scanner/detectors/cicd.ts @@ -0,0 +1,26 @@ +import type { Detector } from "../types.js"; + +export const detectCicd: Detector = async (ctx) => { + const cicd: string[] = []; + + if (ctx.fileTree.some((f) => f.startsWith(".github/"))) { + cicd.push("GitHub Actions"); + } + if (ctx.fileTree.includes(".gitlab-ci.yml")) { + cicd.push("GitLab CI"); + } + if (ctx.fileTree.includes("Jenkinsfile")) { + cicd.push("Jenkins"); + } + if (ctx.fileTree.includes(".circleci/")) { + cicd.push("CircleCI"); + } + if (ctx.fileTree.includes(".travis.yml")) { + cicd.push("Travis CI"); + } + if (ctx.fileTree.includes("Dockerfile") || ctx.fileTree.includes("docker-compose.yml")) { + cicd.push("Docker"); + } + + return { cicd }; +}; diff --git a/src/scanner/detectors/database.ts b/src/scanner/detectors/database.ts new file mode 100644 index 0000000..39148fb --- /dev/null +++ b/src/scanner/detectors/database.ts @@ -0,0 +1,39 @@ +import type { Detector } from "../types.js"; + +export const detectDatabases: Detector = async (ctx) => { + const databases: string[] = []; + const deps = { ...ctx.packageJson?.dependencies, ...ctx.packageJson?.devDependencies }; + + if (deps?.["prisma"] || deps?.["@prisma/client"]) { + databases.push("Prisma"); + } + if (deps?.["drizzle-orm"]) { + databases.push("Drizzle"); + } + if (deps?.["mongoose"]) { + databases.push("Mongoose"); + } + if (deps?.["pg"] || deps?.["postgres"]) { + databases.push("PostgreSQL"); + } + if (deps?.["mysql2"] || deps?.["mysql"]) { + databases.push("MySQL"); + } + if (deps?.["better-sqlite3"] || deps?.["sqlite3"]) { + databases.push("SQLite"); + } + if (deps?.["redis"] || deps?.["ioredis"]) { + databases.push("Redis"); + } + if (deps?.["typeorm"]) { + databases.push("TypeORM"); + } + if (deps?.["sequelize"]) { + databases.push("Sequelize"); + } + if (deps?.["knex"]) { + databases.push("Knex"); + } + + return { databases }; +}; diff --git a/src/scanner/detectors/framework.ts b/src/scanner/detectors/framework.ts new file mode 100644 index 0000000..ab480ae --- /dev/null +++ b/src/scanner/detectors/framework.ts @@ -0,0 +1,115 @@ +import type { Detector, FrameworkInfo } from "../types.js"; + +export const detectFrameworks: Detector = async (ctx) => { + const frameworks: FrameworkInfo[] = []; + const deps = getAllDeps(ctx.packageJson); + + // Frontend frameworks + if (deps["next"]) { + frameworks.push({ + name: "Next.js", + category: "fullstack", + version: deps["next"], + }); + } else if (deps["react"]) { + frameworks.push({ + name: "React", + category: "frontend", + version: deps["react"], + }); + } + if (deps["vue"]) { + frameworks.push({ + name: "Vue", + category: "frontend", + version: deps["vue"], + }); + } + if (deps["nuxt"]) { + frameworks.push({ + name: "Nuxt", + category: "fullstack", + version: deps["nuxt"], + }); + } + if (deps["svelte"] || deps["@sveltejs/kit"]) { + frameworks.push({ + name: deps["@sveltejs/kit"] ? "SvelteKit" : "Svelte", + category: deps["@sveltejs/kit"] ? "fullstack" : "frontend", + version: deps["svelte"] || deps["@sveltejs/kit"], + }); + } + if (deps["angular"] || deps["@angular/core"]) { + frameworks.push({ + name: "Angular", + category: "frontend", + version: deps["@angular/core"] || deps["angular"], + }); + } + + // Backend frameworks + if (deps["express"]) { + frameworks.push({ + name: "Express", + category: "backend", + version: deps["express"], + }); + } + if (deps["fastify"]) { + frameworks.push({ + name: "Fastify", + category: "backend", + version: deps["fastify"], + }); + } + if (deps["hono"]) { + frameworks.push({ + name: "Hono", + category: "backend", + version: deps["hono"], + }); + } + if (deps["koa"]) { + frameworks.push({ + name: "Koa", + category: "backend", + version: deps["koa"], + }); + } + if (deps["nestjs"] || deps["@nestjs/core"]) { + frameworks.push({ + name: "NestJS", + category: "backend", + version: deps["@nestjs/core"] || deps["nestjs"], + }); + } + + // Python frameworks (detected by file tree) + if (ctx.fileTree.some((f) => f === "requirements.txt" || f === "pyproject.toml")) { + // We can't read requirements.txt contents here, but we check common markers + if (ctx.fileTree.some((f) => f.includes("manage.py"))) { + frameworks.push({ name: "Django", category: "fullstack" }); + } + } + + // CLI frameworks + if (deps["commander"]) { + frameworks.push({ + name: "Commander.js", + category: "backend", + version: deps["commander"], + }); + } + + return { frameworks }; +}; + +function getAllDeps( + packageJson: Record | null +): Record { + if (!packageJson) return {}; + return { + ...packageJson.dependencies, + ...packageJson.devDependencies, + }; +} diff --git a/src/scanner/detectors/index.ts b/src/scanner/detectors/index.ts new file mode 100644 index 0000000..62f3bbb --- /dev/null +++ b/src/scanner/detectors/index.ts @@ -0,0 +1,68 @@ +import type { DetectorContext, ScanResult } from "../types.js"; +import { detectLanguages } from "./language.js"; +import { detectFrameworks } from "./framework.js"; +import { detectBuildTools } from "./build-tool.js"; +import { detectTesting } from "./testing.js"; +import { detectLinting } from "./linting.js"; +import { detectStyling } from "./styling.js"; +import { detectStructure } from "./structure.js"; +import { detectPackageManager } from "./package-manager.js"; +import { detectDatabases } from "./database.js"; +import { detectCicd } from "./cicd.js"; + +const detectors = [ + detectLanguages, + detectFrameworks, + detectBuildTools, + detectTesting, + detectLinting, + detectStyling, + detectStructure, + detectPackageManager, + detectDatabases, + detectCicd, +]; + +export async function runDetectors( + ctx: DetectorContext +): Promise { + const results = await Promise.all(detectors.map((d) => d(ctx))); + + const merged: ScanResult = { + projectName: ctx.packageJson?.name ?? null, + languages: [], + frameworks: [], + buildTools: [], + testingTools: [], + linting: [], + styling: [], + packageManager: null, + projectStructure: { directories: [], entryPoints: [] }, + databases: [], + cicd: [], + codeSamples: [], + }; + + for (const result of results) { + if (result.languages) merged.languages.push(...result.languages); + if (result.frameworks) merged.frameworks.push(...result.frameworks); + if (result.buildTools) merged.buildTools.push(...result.buildTools); + if (result.testingTools) merged.testingTools.push(...result.testingTools); + if (result.linting) merged.linting.push(...result.linting); + if (result.styling) merged.styling.push(...result.styling); + if (result.packageManager) merged.packageManager = result.packageManager; + if (result.projectStructure) { + merged.projectStructure.directories.push( + ...result.projectStructure.directories + ); + merged.projectStructure.entryPoints.push( + ...result.projectStructure.entryPoints + ); + } + if (result.databases) merged.databases.push(...result.databases); + if (result.cicd) merged.cicd.push(...result.cicd); + if (result.codeSamples) merged.codeSamples.push(...result.codeSamples); + } + + return merged; +} diff --git a/src/scanner/detectors/language.ts b/src/scanner/detectors/language.ts new file mode 100644 index 0000000..52f681d --- /dev/null +++ b/src/scanner/detectors/language.ts @@ -0,0 +1,53 @@ +import fg from "fast-glob"; +import type { Detector } from "../types.js"; + +export const detectLanguages: Detector = async (ctx) => { + const languages: { name: string; config?: string; fileCount: number }[] = []; + + const counts = await Promise.all([ + countFiles(ctx.root, ["**/*.ts", "**/*.tsx"]).then((n) => ({ + name: "TypeScript", + config: ctx.fileTree.find((f) => f === "tsconfig.json"), + count: n, + })), + countFiles(ctx.root, ["**/*.js", "**/*.jsx", "**/*.mjs", "**/*.cjs"]).then( + (n) => ({ name: "JavaScript", config: undefined, count: n }) + ), + countFiles(ctx.root, ["**/*.py"]).then((n) => ({ + name: "Python", + config: ctx.fileTree.find( + (f) => f === "pyproject.toml" || f === "setup.py" + ), + count: n, + })), + countFiles(ctx.root, ["**/*.go"]).then((n) => ({ + name: "Go", + config: ctx.fileTree.find((f) => f === "go.mod"), + count: n, + })), + countFiles(ctx.root, ["**/*.rs"]).then((n) => ({ + name: "Rust", + config: ctx.fileTree.find((f) => f === "Cargo.toml"), + count: n, + })), + ]); + + for (const { name, config, count } of counts) { + if (count > 0) { + languages.push({ name, config, fileCount: count }); + } + } + + // Sort by file count descending + languages.sort((a, b) => b.fileCount - a.fileCount); + + return { languages }; +}; + +async function countFiles(root: string, patterns: string[]): Promise { + const files = await fg(patterns, { + cwd: root, + ignore: ["node_modules/**", "dist/**", "build/**", ".next/**"], + }); + return files.length; +} diff --git a/src/scanner/detectors/linting.ts b/src/scanner/detectors/linting.ts new file mode 100644 index 0000000..98b696c --- /dev/null +++ b/src/scanner/detectors/linting.ts @@ -0,0 +1,35 @@ +import type { Detector, LintingInfo } from "../types.js"; + +export const detectLinting: Detector = async (ctx) => { + const linting: LintingInfo[] = []; + const deps = { ...ctx.packageJson?.dependencies, ...ctx.packageJson?.devDependencies }; + + if (deps?.["eslint"]) { + const config = ctx.fileTree.find( + (f) => + /^\.?eslint\.(config\.)?(ts|js|mjs|cjs|json|yml|yaml)$/.test(f) || + f === ".eslintrc" || + f === ".eslintrc.json" || + f === ".eslintrc.js" || + f === ".eslintrc.yml" + ); + linting.push({ name: "ESLint", config }); + } + + if (deps?.["prettier"] || ctx.fileTree.some((f) => f === ".prettierrc" || f.startsWith(".prettierrc."))) { + const config = ctx.fileTree.find( + (f) => f === ".prettierrc" || f.startsWith(".prettierrc.") || f === "prettier.config.js" + ); + linting.push({ name: "Prettier", config }); + } + + if (deps?.["biome"] || deps?.["@biomejs/biome"] || ctx.fileTree.some((f) => f === "biome.json")) { + linting.push({ name: "Biome", config: "biome.json" }); + } + + if (ctx.fileTree.some((f) => f === ".editorconfig")) { + linting.push({ name: "EditorConfig", config: ".editorconfig" }); + } + + return { linting }; +}; diff --git a/src/scanner/detectors/package-manager.ts b/src/scanner/detectors/package-manager.ts new file mode 100644 index 0000000..58d1d70 --- /dev/null +++ b/src/scanner/detectors/package-manager.ts @@ -0,0 +1,17 @@ +import type { Detector } from "../types.js"; + +export const detectPackageManager: Detector = async (ctx) => { + if (ctx.fileTree.includes("pnpm-lock.yaml")) { + return { packageManager: "pnpm" }; + } + if (ctx.fileTree.includes("yarn.lock")) { + return { packageManager: "yarn" }; + } + if (ctx.fileTree.includes("bun.lockb") || ctx.fileTree.includes("bun.lock")) { + return { packageManager: "bun" }; + } + if (ctx.fileTree.includes("package-lock.json")) { + return { packageManager: "npm" }; + } + return { packageManager: null }; +}; diff --git a/src/scanner/detectors/structure.ts b/src/scanner/detectors/structure.ts new file mode 100644 index 0000000..2ee322c --- /dev/null +++ b/src/scanner/detectors/structure.ts @@ -0,0 +1,54 @@ +import type { Detector, ProjectStructure } from "../types.js"; + +export const detectStructure: Detector = async (ctx) => { + const directories = ctx.fileTree + .filter((f) => f.endsWith("/")) + .map((f) => f.replace(/\/$/, "")); + + const entryPoints: string[] = []; + + // Check common entry points + const entryPointCandidates = [ + "src/index.ts", + "src/index.js", + "src/main.ts", + "src/main.js", + "src/app.ts", + "src/app.js", + "src/cli.ts", + "src/cli.js", + "index.ts", + "index.js", + "main.ts", + "main.py", + "app.py", + "main.go", + "src/main.rs", + "src/lib.rs", + ]; + + for (const candidate of entryPointCandidates) { + if (ctx.fileTree.includes(candidate)) { + entryPoints.push(candidate); + } + } + + // Check package.json main/bin fields + if (ctx.packageJson?.main && !entryPoints.includes(ctx.packageJson.main)) { + entryPoints.push(ctx.packageJson.main); + } + if (ctx.packageJson?.bin) { + const bins = + typeof ctx.packageJson.bin === "string" + ? [ctx.packageJson.bin] + : Object.values(ctx.packageJson.bin) as string[]; + for (const b of bins) { + if (!entryPoints.includes(b)) { + entryPoints.push(b); + } + } + } + + const projectStructure: ProjectStructure = { directories, entryPoints }; + return { projectStructure }; +}; diff --git a/src/scanner/detectors/styling.ts b/src/scanner/detectors/styling.ts new file mode 100644 index 0000000..ffa7395 --- /dev/null +++ b/src/scanner/detectors/styling.ts @@ -0,0 +1,29 @@ +import type { Detector, StylingInfo } from "../types.js"; + +export const detectStyling: Detector = async (ctx) => { + const styling: StylingInfo[] = []; + const deps = { ...ctx.packageJson?.dependencies, ...ctx.packageJson?.devDependencies }; + + if (deps?.["tailwindcss"] || ctx.fileTree.some((f) => f.startsWith("tailwind.config"))) { + styling.push({ name: "Tailwind CSS" }); + } + + if (deps?.["sass"] || deps?.["node-sass"]) { + styling.push({ name: "SCSS/Sass" }); + } + + if (deps?.["styled-components"]) { + styling.push({ name: "styled-components" }); + } + + if (deps?.["@emotion/react"] || deps?.["@emotion/styled"]) { + styling.push({ name: "Emotion" }); + } + + // CSS Modules detection via file tree + if (ctx.fileTree.some((f) => f.includes(".module.css") || f.includes(".module.scss"))) { + styling.push({ name: "CSS Modules" }); + } + + return { styling }; +}; diff --git a/src/scanner/detectors/testing.ts b/src/scanner/detectors/testing.ts new file mode 100644 index 0000000..0e16ccd --- /dev/null +++ b/src/scanner/detectors/testing.ts @@ -0,0 +1,54 @@ +import type { Detector, TestingToolInfo } from "../types.js"; + +export const detectTesting: Detector = async (ctx) => { + const testingTools: TestingToolInfo[] = []; + const deps = { ...ctx.packageJson?.dependencies, ...ctx.packageJson?.devDependencies }; + + if (deps?.["vitest"]) { + const config = ctx.fileTree.find((f) => + /^vitest\.config\.(ts|js|mjs)$/.test(f) + ); + testingTools.push({ + name: "Vitest", + config, + testPattern: "**/*.test.{ts,tsx,js,jsx}", + }); + } + + if (deps?.["jest"] || ctx.fileTree.some((f) => f === "jest.config.js" || f === "jest.config.ts")) { + const config = ctx.fileTree.find((f) => f.startsWith("jest.config")); + testingTools.push({ + name: "Jest", + config, + testPattern: "**/*.test.{ts,tsx,js,jsx}", + }); + } + + if (deps?.["mocha"]) { + testingTools.push({ name: "Mocha", testPattern: "test/**/*.{ts,js}" }); + } + + if (deps?.["playwright"] || deps?.["@playwright/test"]) { + const config = ctx.fileTree.find((f) => f.startsWith("playwright.config")); + testingTools.push({ + name: "Playwright", + config, + testPattern: "**/*.spec.{ts,js}", + }); + } + + if (deps?.["cypress"]) { + const config = ctx.fileTree.find((f) => f.startsWith("cypress.config")); + testingTools.push({ + name: "Cypress", + config, + testPattern: "cypress/e2e/**/*.cy.{ts,js}", + }); + } + + if (deps?.["@testing-library/react"]) { + testingTools.push({ name: "React Testing Library" }); + } + + return { testingTools }; +}; diff --git a/src/scanner/formatter.ts b/src/scanner/formatter.ts new file mode 100644 index 0000000..01a01d1 --- /dev/null +++ b/src/scanner/formatter.ts @@ -0,0 +1,137 @@ +import type { ScanResult } from "./types.js"; + +export function formatMarkdown(result: ScanResult): string { + const sections: string[] = []; + + // Header + const title = result.projectName || "Project"; + sections.push(`# Codebase Analysis — ${title}\n`); + + // Tech Stack + sections.push("## Tech Stack\n"); + if (result.languages.length > 0) { + const langs = result.languages + .map((l) => `${l.name} (${l.fileCount} files)`) + .join(", "); + sections.push(`- **Languages**: ${langs}`); + } + for (const fw of result.frameworks) { + const ver = fw.version ? ` ${fw.version}` : ""; + sections.push(`- **${capitalize(fw.category)}**: ${fw.name}${ver}`); + } + for (const bt of result.buildTools) { + const cfg = bt.config ? ` (${bt.config})` : ""; + sections.push(`- **Build**: ${bt.name}${cfg}`); + } + for (const tt of result.testingTools) { + const cfg = tt.config ? ` (${tt.config})` : ""; + sections.push(`- **Testing**: ${tt.name}${cfg}`); + } + for (const l of result.linting) { + const cfg = l.config ? ` (${l.config})` : ""; + sections.push(`- **Linting**: ${l.name}${cfg}`); + } + if (result.styling.length > 0) { + sections.push( + `- **Styling**: ${result.styling.map((s) => s.name).join(", ")}` + ); + } + if (result.databases.length > 0) { + sections.push(`- **Database**: ${result.databases.join(", ")}`); + } + if (result.cicd.length > 0) { + sections.push(`- **CI/CD**: ${result.cicd.join(", ")}`); + } + if (result.packageManager) { + sections.push(`- **Package Manager**: ${result.packageManager}`); + } + sections.push(""); + + // Project Structure + sections.push("## Project Structure\n"); + if (result.projectStructure.directories.length > 0) { + sections.push("**Directories:**"); + for (const dir of result.projectStructure.directories) { + sections.push(`- ${dir}/`); + } + sections.push(""); + } + if (result.projectStructure.entryPoints.length > 0) { + sections.push("**Entry Points:**"); + for (const ep of result.projectStructure.entryPoints) { + sections.push(`- ${ep}`); + } + sections.push(""); + } + + // Code Samples + if (result.codeSamples.length > 0) { + sections.push("## Code Samples\n"); + for (const sample of result.codeSamples) { + const categoryLabel = capitalize(sample.category); + sections.push(`### ${categoryLabel} Example (${sample.filePath})\n`); + + const ext = sample.filePath.split(".").pop() || ""; + const lang = extToLang(ext); + sections.push("```" + lang); + sections.push(sample.excerpt); + sections.push("```\n"); + + if (sample.patterns.length > 0) { + sections.push(`Patterns: ${sample.patterns.join(", ")}\n`); + } + } + } + + // Instructions + sections.push(INSTRUCTIONS_SECTION); + + return sections.join("\n"); +} + +export function formatJson(result: ScanResult): string { + return JSON.stringify(result, null, 2); +} + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +function extToLang(ext: string): string { + const map: Record = { + ts: "typescript", + tsx: "tsx", + js: "javascript", + jsx: "jsx", + py: "python", + go: "go", + rs: "rust", + vue: "vue", + svelte: "svelte", + prisma: "prisma", + }; + return map[ext] || ext; +} + +const INSTRUCTIONS_SECTION = `## Instructions for AI Agent + +Using the analysis above, generate the following OpenSpec module files in \`.openspec/modules/\`: + +1. **shared.md** — Project-wide rules: tech stack summary, coding style (based on detected patterns), directory conventions, build/lint commands +2. **frontend.md** — Frontend rules derived from the component samples: component patterns, styling approach, state management, routing conventions +3. **backend.md** — Backend rules derived from API samples: route handler patterns, validation approach, error handling, database access patterns +4. **testing.md** — Testing rules derived from test samples: framework conventions, file naming, assertion patterns, what to mock + +Each file must use this frontmatter format: +\`\`\`yaml +--- +name:
+description: +priority: <10 for shared, 20 for frontend/backend, 30 for testing> +tags: [] +--- +\`\`\` + +Write rules that reflect the **actual patterns** found in the code samples above, not generic best practices. +After writing the modules, run \`openspec sync\` to generate all AI context files. +`; diff --git a/src/scanner/sampler.ts b/src/scanner/sampler.ts new file mode 100644 index 0000000..fa32fe1 --- /dev/null +++ b/src/scanner/sampler.ts @@ -0,0 +1,197 @@ +import { readFile } from "fs/promises"; +import { join, relative } from "path"; +import fg from "fast-glob"; +import type { CodeSample, ScanResult } from "./types.js"; + +interface SampleCategory { + category: CodeSample["category"]; + patterns: string[]; + maxFiles: number; +} + +const SAMPLE_CATEGORIES: SampleCategory[] = [ + { + category: "component", + patterns: [ + "src/components/**/*.{tsx,vue,svelte}", + "src/app/**/*.tsx", + "app/**/*.tsx", + "components/**/*.{tsx,vue,svelte}", + ], + maxFiles: 3, + }, + { + category: "api", + patterns: [ + "src/api/**/*.ts", + "src/routes/**/*.ts", + "app/api/**/*.ts", + "src/server/**/*.ts", + "routes/**/*.ts", + "src/api/**/*.js", + "src/routes/**/*.js", + ], + maxFiles: 3, + }, + { + category: "model", + patterns: [ + "src/models/**/*", + "prisma/schema.prisma", + "src/db/**/*.ts", + "src/schema/**/*.ts", + "models/**/*.ts", + ], + maxFiles: 2, + }, + { + category: "test", + patterns: [ + "**/*.test.{ts,tsx,js,jsx}", + "**/*.spec.{ts,tsx,js,jsx}", + "tests/**/*.{ts,js}", + ], + maxFiles: 3, + }, + { + category: "util", + patterns: [ + "src/utils/**/*.{ts,js}", + "src/lib/**/*.{ts,js}", + "src/helpers/**/*.{ts,js}", + "lib/**/*.{ts,js}", + "utils/**/*.{ts,js}", + ], + maxFiles: 2, + }, + { + category: "config", + patterns: [ + "src/config.{ts,js}", + "src/config/**/*.{ts,js}", + "config/**/*.{ts,js}", + ], + maxFiles: 1, + }, +]; + +const MAX_LINES = 50; +const IGNORE_PATTERNS = [ + "node_modules/**", + "dist/**", + "build/**", + ".next/**", + "coverage/**", +]; + +export async function sampleSourceFiles( + root: string, + _scanResult: ScanResult +): Promise { + const samples: CodeSample[] = []; + + for (const cat of SAMPLE_CATEGORIES) { + const files = await fg(cat.patterns, { + cwd: root, + ignore: IGNORE_PATTERNS, + absolute: false, + }); + + const selected = files.slice(0, cat.maxFiles); + + for (const filePath of selected) { + try { + const content = await readFile(join(root, filePath), "utf-8"); + const lines = content.split("\n").slice(0, MAX_LINES); + const excerpt = lines.join("\n"); + const patterns = detectPatterns(content, cat.category); + + samples.push({ + filePath: relative(root, join(root, filePath)).replace(/\\/g, "/"), + category: cat.category, + excerpt, + patterns, + }); + } catch { + // Skip unreadable files + } + } + } + + return samples; +} + +function detectPatterns(content: string, category: CodeSample["category"]): string[] { + const patterns: string[] = []; + + // Import patterns + if (content.includes("from '@/") || content.includes('from "@/')) { + patterns.push("path-aliases"); + } + if (/from ['"]\.\/index['"]/.test(content) || /from ['"]\.\.\/['"]/.test(content)) { + patterns.push("barrel-imports"); + } + + // Export patterns + if (/^export default /m.test(content)) { + patterns.push("default-exports"); + } + if (/^export (const|function|class|interface|type) /m.test(content)) { + patterns.push("named-exports"); + } + + // Component patterns + if (category === "component") { + if (/use[A-Z]\w*\(/.test(content)) { + patterns.push("hooks"); + } + if (/className=/.test(content)) { + patterns.push("tailwind-classes"); + } + if (/styled\.\w+/.test(content) || /styled\(/.test(content)) { + patterns.push("styled-components"); + } + } + + // API patterns + if (category === "api") { + if (/async\s+(function|\(|[a-z])/.test(content)) { + patterns.push("async-handlers"); + } + if (content.includes("zod") || /z\.\w+/.test(content)) { + patterns.push("zod-validation"); + } + if (/try\s*\{/.test(content)) { + patterns.push("try-catch-errors"); + } + if (/\.use\(/.test(content)) { + patterns.push("middleware"); + } + } + + // Test patterns + if (category === "test") { + if (/describe\s*\(/.test(content)) { + patterns.push("describe-blocks"); + } + if (/it\s*\(/.test(content) || /test\s*\(/.test(content)) { + patterns.push("test-blocks"); + } + if (content.includes("@testing-library") || content.includes("render(")) { + patterns.push("testing-library"); + } + if (content.includes("vi.") || content.includes("vitest")) { + patterns.push("vitest"); + } + if (content.includes("jest.") || content.includes("@jest")) { + patterns.push("jest"); + } + } + + // General patterns + if (/interface\s+\w+/.test(content) || /type\s+\w+\s*=/.test(content)) { + patterns.push("typescript-types"); + } + + return patterns; +} diff --git a/src/scanner/types.ts b/src/scanner/types.ts new file mode 100644 index 0000000..ab5b9f1 --- /dev/null +++ b/src/scanner/types.ts @@ -0,0 +1,66 @@ +export interface DetectorContext { + root: string; + packageJson: Record | null; + fileTree: string[]; +} + +export interface LanguageInfo { + name: string; + config?: string; + fileCount: number; +} + +export interface FrameworkInfo { + name: string; + category: "frontend" | "backend" | "fullstack"; + version?: string; +} + +export interface BuildToolInfo { + name: string; + config?: string; +} + +export interface TestingToolInfo { + name: string; + config?: string; + testPattern?: string; +} + +export interface LintingInfo { + name: string; + config?: string; +} + +export interface StylingInfo { + name: string; +} + +export interface ProjectStructure { + directories: string[]; + entryPoints: string[]; +} + +export interface CodeSample { + filePath: string; + category: "component" | "api" | "model" | "test" | "config" | "util"; + excerpt: string; + patterns: string[]; +} + +export interface ScanResult { + projectName: string | null; + languages: LanguageInfo[]; + frameworks: FrameworkInfo[]; + buildTools: BuildToolInfo[]; + testingTools: TestingToolInfo[]; + linting: LintingInfo[]; + styling: StylingInfo[]; + packageManager: string | null; + projectStructure: ProjectStructure; + databases: string[]; + cicd: string[]; + codeSamples: CodeSample[]; +} + +export type Detector = (ctx: DetectorContext) => Promise>; diff --git a/tests/commands/generate.test.ts b/tests/commands/generate.test.ts new file mode 100644 index 0000000..a4b00fe --- /dev/null +++ b/tests/commands/generate.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm, writeFile, mkdir } from "fs/promises"; +import { join } from "path"; +import { tmpdir } from "os"; +import { buildContext } from "../../src/scanner/context.js"; +import { runDetectors } from "../../src/scanner/detectors/index.js"; +import { sampleSourceFiles } from "../../src/scanner/sampler.js"; +import { formatMarkdown, formatJson } from "../../src/scanner/formatter.js"; + +let tempDir: string; + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "openspec-gen-")); +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true }); +}); + +async function setupRealisticProject(root: string) { + // package.json + await writeFile( + join(root, "package.json"), + JSON.stringify({ + name: "test-app", + version: "1.0.0", + scripts: { build: "tsc", test: "vitest run", dev: "vite" }, + dependencies: { + react: "^18.2.0", + express: "^4.18.0", + "@prisma/client": "^5.0.0", + }, + devDependencies: { + typescript: "^5.0.0", + vitest: "^1.0.0", + eslint: "^8.0.0", + tailwindcss: "^3.0.0", + vite: "^5.0.0", + }, + }) + ); + + // Config files + await writeFile(join(root, "tsconfig.json"), "{}"); + await writeFile(join(root, "vitest.config.ts"), "export default {};"); + await writeFile(join(root, "vite.config.ts"), "export default {};"); + await writeFile(join(root, ".eslintrc.json"), "{}"); + await writeFile(join(root, "package-lock.json"), "{}"); + + // Source files + await mkdir(join(root, "src", "components"), { recursive: true }); + await mkdir(join(root, "src", "api"), { recursive: true }); + await mkdir(join(root, "tests"), { recursive: true }); + await mkdir(join(root, ".github", "workflows"), { recursive: true }); + + await writeFile( + join(root, "src", "index.ts"), + 'import express from "express";\nconst app = express();\n' + ); + + await writeFile( + join(root, "src", "components", "Header.tsx"), + `import { useState } from 'react'; + +export function Header({ title }: { title: string }) { + const [isOpen, setIsOpen] = useState(false); + return
{title}
; +} +` + ); + + await writeFile( + join(root, "src", "api", "users.ts"), + `import { Router } from 'express'; + +export const usersRouter = Router(); + +usersRouter.get('/', async (req, res) => { + try { + const users = await db.user.findMany(); + res.json(users); + } catch (err) { + res.status(500).json({ error: 'Failed to fetch users' }); + } +}); +` + ); + + await writeFile( + join(root, "tests", "app.test.ts"), + `import { describe, it, expect } from 'vitest'; + +describe('App', () => { + it('should start correctly', () => { + expect(true).toBe(true); + }); +}); +` + ); + + await writeFile( + join(root, ".github", "workflows", "ci.yml"), + "name: CI\non: [push]" + ); +} + +describe("generate integration", () => { + it("should produce full markdown output for a realistic project", async () => { + await setupRealisticProject(tempDir); + + const ctx = await buildContext(tempDir); + const scanResult = await runDetectors(ctx); + scanResult.codeSamples = await sampleSourceFiles(tempDir, scanResult); + const output = formatMarkdown(scanResult); + + // Tech stack + expect(output).toContain("# Codebase Analysis — test-app"); + expect(output).toContain("TypeScript"); + expect(output).toContain("React"); + expect(output).toContain("Express"); + expect(output).toContain("Vite"); + expect(output).toContain("Vitest"); + expect(output).toContain("ESLint"); + expect(output).toContain("Tailwind CSS"); + expect(output).toContain("Prisma"); + expect(output).toContain("GitHub Actions"); + expect(output).toContain("npm"); + + // Structure + expect(output).toContain("src/index.ts"); + + // Code samples + expect(output).toContain("## Code Samples"); + + // Instructions + expect(output).toContain("## Instructions for AI Agent"); + }); + + it("should produce valid JSON output for a realistic project", async () => { + await setupRealisticProject(tempDir); + + const ctx = await buildContext(tempDir); + const scanResult = await runDetectors(ctx); + scanResult.codeSamples = await sampleSourceFiles(tempDir, scanResult); + const output = formatJson(scanResult); + + const parsed = JSON.parse(output); + expect(parsed.projectName).toBe("test-app"); + expect(parsed.languages.length).toBeGreaterThan(0); + expect(parsed.frameworks.length).toBeGreaterThan(0); + expect(parsed.codeSamples.length).toBeGreaterThan(0); + expect(parsed.packageManager).toBe("npm"); + }); + + it("should handle empty project gracefully", async () => { + const ctx = await buildContext(tempDir); + const scanResult = await runDetectors(ctx); + scanResult.codeSamples = await sampleSourceFiles(tempDir, scanResult); + const output = formatMarkdown(scanResult); + + expect(output).toContain("# Codebase Analysis — Project"); + expect(output).toContain("## Instructions for AI Agent"); + }); +}); diff --git a/tests/scanner/detectors.test.ts b/tests/scanner/detectors.test.ts new file mode 100644 index 0000000..7f77772 --- /dev/null +++ b/tests/scanner/detectors.test.ts @@ -0,0 +1,315 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm, writeFile, mkdir } from "fs/promises"; +import { join } from "path"; +import { tmpdir } from "os"; +import { buildContext } from "../../src/scanner/context.js"; +import { runDetectors } from "../../src/scanner/detectors/index.js"; + +let tempDir: string; + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "openspec-detect-")); +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true }); +}); + +describe("language detector", () => { + it("should detect TypeScript from .ts files", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await writeFile(join(tempDir, "src", "index.ts"), "export const x = 1;"); + await writeFile(join(tempDir, "tsconfig.json"), "{}"); + + const ctx = await buildContext(tempDir); + const result = await runDetectors(ctx); + + expect(result.languages).toContainEqual( + expect.objectContaining({ name: "TypeScript", fileCount: 1 }) + ); + }); + + it("should detect JavaScript from .js files", async () => { + await writeFile(join(tempDir, "index.js"), "module.exports = {};"); + + const ctx = await buildContext(tempDir); + const result = await runDetectors(ctx); + + expect(result.languages).toContainEqual( + expect.objectContaining({ name: "JavaScript", fileCount: 1 }) + ); + }); +}); + +describe("framework detector", () => { + it("should detect React from package.json dependencies", async () => { + await writeFile( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { react: "^18.2.0" } }) + ); + + const ctx = await buildContext(tempDir); + const result = await runDetectors(ctx); + + expect(result.frameworks).toContainEqual( + expect.objectContaining({ + name: "React", + category: "frontend", + version: "^18.2.0", + }) + ); + }); + + it("should detect Next.js as fullstack", async () => { + await writeFile( + join(tempDir, "package.json"), + JSON.stringify({ + dependencies: { next: "^14.0.0", react: "^18.2.0" }, + }) + ); + + const ctx = await buildContext(tempDir); + const result = await runDetectors(ctx); + + expect(result.frameworks).toContainEqual( + expect.objectContaining({ name: "Next.js", category: "fullstack" }) + ); + // React should NOT appear separately when Next.js is present + expect(result.frameworks).not.toContainEqual( + expect.objectContaining({ name: "React" }) + ); + }); + + it("should detect Express as backend", async () => { + await writeFile( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { express: "^4.18.0" } }) + ); + + const ctx = await buildContext(tempDir); + const result = await runDetectors(ctx); + + expect(result.frameworks).toContainEqual( + expect.objectContaining({ name: "Express", category: "backend" }) + ); + }); +}); + +describe("build tool detector", () => { + it("should detect Vite from devDependencies", async () => { + await writeFile( + join(tempDir, "package.json"), + JSON.stringify({ devDependencies: { vite: "^5.0.0" } }) + ); + await writeFile(join(tempDir, "vite.config.ts"), "export default {};"); + + const ctx = await buildContext(tempDir); + const result = await runDetectors(ctx); + + expect(result.buildTools).toContainEqual( + expect.objectContaining({ name: "Vite", config: "vite.config.ts" }) + ); + }); + + it("should detect tsc when used in scripts", async () => { + await writeFile( + join(tempDir, "package.json"), + JSON.stringify({ scripts: { build: "tsc" }, devDependencies: {} }) + ); + await writeFile(join(tempDir, "tsconfig.json"), "{}"); + + const ctx = await buildContext(tempDir); + const result = await runDetectors(ctx); + + expect(result.buildTools).toContainEqual( + expect.objectContaining({ name: "tsc" }) + ); + }); +}); + +describe("testing detector", () => { + it("should detect Vitest from devDependencies", async () => { + await writeFile( + join(tempDir, "package.json"), + JSON.stringify({ devDependencies: { vitest: "^1.0.0" } }) + ); + await writeFile(join(tempDir, "vitest.config.ts"), "export default {};"); + + const ctx = await buildContext(tempDir); + const result = await runDetectors(ctx); + + expect(result.testingTools).toContainEqual( + expect.objectContaining({ name: "Vitest", config: "vitest.config.ts" }) + ); + }); + + it("should detect Jest from devDependencies", async () => { + await writeFile( + join(tempDir, "package.json"), + JSON.stringify({ devDependencies: { jest: "^29.0.0" } }) + ); + + const ctx = await buildContext(tempDir); + const result = await runDetectors(ctx); + + expect(result.testingTools).toContainEqual( + expect.objectContaining({ name: "Jest" }) + ); + }); +}); + +describe("linting detector", () => { + it("should detect ESLint from devDependencies", async () => { + await writeFile( + join(tempDir, "package.json"), + JSON.stringify({ devDependencies: { eslint: "^8.0.0" } }) + ); + await writeFile(join(tempDir, ".eslintrc.json"), "{}"); + + const ctx = await buildContext(tempDir); + const result = await runDetectors(ctx); + + expect(result.linting).toContainEqual( + expect.objectContaining({ name: "ESLint" }) + ); + }); + + it("should detect Prettier from config file", async () => { + await writeFile(join(tempDir, "package.json"), JSON.stringify({ devDependencies: { prettier: "^3.0.0" } })); + await writeFile(join(tempDir, ".prettierrc"), "{}"); + + const ctx = await buildContext(tempDir); + const result = await runDetectors(ctx); + + expect(result.linting).toContainEqual( + expect.objectContaining({ name: "Prettier", config: ".prettierrc" }) + ); + }); +}); + +describe("package manager detector", () => { + it("should detect npm from package-lock.json", async () => { + await writeFile(join(tempDir, "package-lock.json"), "{}"); + + const ctx = await buildContext(tempDir); + const result = await runDetectors(ctx); + + expect(result.packageManager).toBe("npm"); + }); + + it("should detect pnpm from pnpm-lock.yaml", async () => { + await writeFile(join(tempDir, "pnpm-lock.yaml"), ""); + + const ctx = await buildContext(tempDir); + const result = await runDetectors(ctx); + + expect(result.packageManager).toBe("pnpm"); + }); + + it("should detect yarn from yarn.lock", async () => { + await writeFile(join(tempDir, "yarn.lock"), ""); + + const ctx = await buildContext(tempDir); + const result = await runDetectors(ctx); + + expect(result.packageManager).toBe("yarn"); + }); +}); + +describe("database detector", () => { + it("should detect Prisma from dependencies", async () => { + await writeFile( + join(tempDir, "package.json"), + JSON.stringify({ dependencies: { "@prisma/client": "^5.0.0" } }) + ); + + const ctx = await buildContext(tempDir); + const result = await runDetectors(ctx); + + expect(result.databases).toContain("Prisma"); + }); +}); + +describe("cicd detector", () => { + it("should detect GitHub Actions from .github directory", async () => { + await mkdir(join(tempDir, ".github", "workflows"), { recursive: true }); + await writeFile( + join(tempDir, ".github", "workflows", "ci.yml"), + "name: CI" + ); + + const ctx = await buildContext(tempDir); + const result = await runDetectors(ctx); + + expect(result.cicd).toContain("GitHub Actions"); + }); + + it("should detect Docker from Dockerfile", async () => { + await writeFile(join(tempDir, "Dockerfile"), "FROM node:18"); + + const ctx = await buildContext(tempDir); + const result = await runDetectors(ctx); + + expect(result.cicd).toContain("Docker"); + }); +}); + +describe("structure detector", () => { + it("should detect entry points", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await writeFile(join(tempDir, "src", "index.ts"), "export {};"); + await writeFile(join(tempDir, "package.json"), JSON.stringify({})); + + const ctx = await buildContext(tempDir); + const result = await runDetectors(ctx); + + expect(result.projectStructure.entryPoints).toContain("src/index.ts"); + }); + + it("should detect directories", async () => { + await mkdir(join(tempDir, "src"), { recursive: true }); + await writeFile(join(tempDir, "src", "index.ts"), ""); + + const ctx = await buildContext(tempDir); + const result = await runDetectors(ctx); + + expect(result.projectStructure.directories).toContain("src"); + }); +}); + +describe("styling detector", () => { + it("should detect Tailwind CSS from dependencies", async () => { + await writeFile( + join(tempDir, "package.json"), + JSON.stringify({ devDependencies: { tailwindcss: "^3.0.0" } }) + ); + + const ctx = await buildContext(tempDir); + const result = await runDetectors(ctx); + + expect(result.styling).toContainEqual( + expect.objectContaining({ name: "Tailwind CSS" }) + ); + }); +}); + +describe("project name", () => { + it("should extract project name from package.json", async () => { + await writeFile( + join(tempDir, "package.json"), + JSON.stringify({ name: "my-app" }) + ); + + const ctx = await buildContext(tempDir); + const result = await runDetectors(ctx); + + expect(result.projectName).toBe("my-app"); + }); + + it("should return null when no package.json", async () => { + const ctx = await buildContext(tempDir); + const result = await runDetectors(ctx); + + expect(result.projectName).toBeNull(); + }); +}); diff --git a/tests/scanner/formatter.test.ts b/tests/scanner/formatter.test.ts new file mode 100644 index 0000000..5b64734 --- /dev/null +++ b/tests/scanner/formatter.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from "vitest"; +import { formatMarkdown, formatJson } from "../../src/scanner/formatter.js"; +import type { ScanResult } from "../../src/scanner/types.js"; + +const sampleResult: ScanResult = { + projectName: "my-app", + languages: [ + { name: "TypeScript", config: "tsconfig.json", fileCount: 42 }, + { name: "JavaScript", fileCount: 3 }, + ], + frameworks: [ + { name: "React", category: "frontend", version: "^18.2.0" }, + { name: "Express", category: "backend", version: "^4.18.0" }, + ], + buildTools: [{ name: "Vite", config: "vite.config.ts" }], + testingTools: [ + { name: "Vitest", config: "vitest.config.ts", testPattern: "**/*.test.ts" }, + ], + linting: [{ name: "ESLint", config: ".eslintrc.json" }], + styling: [{ name: "Tailwind CSS" }], + packageManager: "pnpm", + projectStructure: { + directories: ["src", "tests", "src/components"], + entryPoints: ["src/index.ts"], + }, + databases: ["Prisma"], + cicd: ["GitHub Actions"], + codeSamples: [ + { + filePath: "src/components/Button.tsx", + category: "component", + excerpt: 'export function Button() { return ; }', + patterns: ["named-exports", "hooks", "tailwind-classes"], + }, + { + filePath: "src/api/users.ts", + category: "api", + excerpt: "export async function getUsers(req, res) { }", + patterns: ["async-handlers"], + }, + ], +}; + +describe("formatMarkdown", () => { + it("should include project name in title", () => { + const output = formatMarkdown(sampleResult); + expect(output).toContain("# Codebase Analysis — my-app"); + }); + + it("should include tech stack section", () => { + const output = formatMarkdown(sampleResult); + expect(output).toContain("## Tech Stack"); + expect(output).toContain("TypeScript (42 files)"); + expect(output).toContain("React ^18.2.0"); + expect(output).toContain("Express ^4.18.0"); + expect(output).toContain("Vite (vite.config.ts)"); + expect(output).toContain("Vitest (vitest.config.ts)"); + expect(output).toContain("ESLint (.eslintrc.json)"); + expect(output).toContain("Tailwind CSS"); + expect(output).toContain("Prisma"); + expect(output).toContain("GitHub Actions"); + expect(output).toContain("pnpm"); + }); + + it("should include project structure section", () => { + const output = formatMarkdown(sampleResult); + expect(output).toContain("## Project Structure"); + expect(output).toContain("src/"); + expect(output).toContain("src/index.ts"); + }); + + it("should include code samples section", () => { + const output = formatMarkdown(sampleResult); + expect(output).toContain("## Code Samples"); + expect(output).toContain("### Component Example (src/components/Button.tsx)"); + expect(output).toContain("### Api Example (src/api/users.ts)"); + expect(output).toContain("Patterns: named-exports, hooks, tailwind-classes"); + }); + + it("should include instructions section", () => { + const output = formatMarkdown(sampleResult); + expect(output).toContain("## Instructions for AI Agent"); + expect(output).toContain("shared.md"); + expect(output).toContain("frontend.md"); + expect(output).toContain("backend.md"); + expect(output).toContain("testing.md"); + expect(output).toContain("openspec sync"); + }); + + it("should use 'Project' as default title when no project name", () => { + const result = { ...sampleResult, projectName: null }; + const output = formatMarkdown(result); + expect(output).toContain("# Codebase Analysis — Project"); + }); +}); + +describe("formatJson", () => { + it("should output valid JSON", () => { + const output = formatJson(sampleResult); + const parsed = JSON.parse(output); + expect(parsed).toBeDefined(); + }); + + it("should include all fields", () => { + const output = formatJson(sampleResult); + const parsed = JSON.parse(output); + expect(parsed.projectName).toBe("my-app"); + expect(parsed.languages).toHaveLength(2); + expect(parsed.frameworks).toHaveLength(2); + expect(parsed.codeSamples).toHaveLength(2); + expect(parsed.packageManager).toBe("pnpm"); + expect(parsed.databases).toContain("Prisma"); + }); +}); diff --git a/tests/scanner/sampler.test.ts b/tests/scanner/sampler.test.ts new file mode 100644 index 0000000..6b32111 --- /dev/null +++ b/tests/scanner/sampler.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm, writeFile, mkdir } from "fs/promises"; +import { join } from "path"; +import { tmpdir } from "os"; +import { sampleSourceFiles } from "../../src/scanner/sampler.js"; +import type { ScanResult } from "../../src/scanner/types.js"; + +let tempDir: string; + +const emptyScanResult: ScanResult = { + projectName: null, + languages: [], + frameworks: [], + buildTools: [], + testingTools: [], + linting: [], + styling: [], + packageManager: null, + projectStructure: { directories: [], entryPoints: [] }, + databases: [], + cicd: [], + codeSamples: [], +}; + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "openspec-sample-")); +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true }); +}); + +describe("sampleSourceFiles", () => { + it("should sample component files", async () => { + await mkdir(join(tempDir, "src", "components"), { recursive: true }); + await writeFile( + join(tempDir, "src", "components", "Button.tsx"), + `import React from 'react'; + +export function Button({ label }: { label: string }) { + return ; +} +` + ); + + const samples = await sampleSourceFiles(tempDir, emptyScanResult); + + expect(samples).toHaveLength(1); + expect(samples[0].category).toBe("component"); + expect(samples[0].filePath).toBe("src/components/Button.tsx"); + expect(samples[0].excerpt).toContain("export function Button"); + }); + + it("should detect patterns in component files", async () => { + await mkdir(join(tempDir, "src", "components"), { recursive: true }); + await writeFile( + join(tempDir, "src", "components", "UserCard.tsx"), + `import { useState } from 'react'; + +export function UserCard({ name }: { name: string }) { + const [isOpen, setIsOpen] = useState(false); + return
{name}
; +} +` + ); + + const samples = await sampleSourceFiles(tempDir, emptyScanResult); + + expect(samples[0].patterns).toContain("hooks"); + expect(samples[0].patterns).toContain("tailwind-classes"); + expect(samples[0].patterns).toContain("named-exports"); + }); + + it("should sample test files and detect test patterns", async () => { + await mkdir(join(tempDir, "tests"), { recursive: true }); + await writeFile( + join(tempDir, "tests", "app.test.ts"), + `import { describe, it, expect, vi } from 'vitest'; + +describe('App', () => { + it('should work', () => { + expect(true).toBe(true); + }); +}); +` + ); + + const samples = await sampleSourceFiles(tempDir, emptyScanResult); + + const testSample = samples.find((s) => s.category === "test"); + expect(testSample).toBeDefined(); + expect(testSample!.patterns).toContain("describe-blocks"); + expect(testSample!.patterns).toContain("vitest"); + }); + + it("should limit excerpt to 50 lines", async () => { + await mkdir(join(tempDir, "src", "components"), { recursive: true }); + const longContent = Array.from({ length: 100 }, (_, i) => `// line ${i + 1}`).join("\n"); + await writeFile(join(tempDir, "src", "components", "Long.tsx"), longContent); + + const samples = await sampleSourceFiles(tempDir, emptyScanResult); + + const lines = samples[0].excerpt.split("\n"); + expect(lines.length).toBeLessThanOrEqual(50); + }); + + it("should limit number of files per category", async () => { + await mkdir(join(tempDir, "src", "components"), { recursive: true }); + for (let i = 0; i < 10; i++) { + await writeFile( + join(tempDir, "src", "components", `Comp${i}.tsx`), + `export function Comp${i}() { return
; }` + ); + } + + const samples = await sampleSourceFiles(tempDir, emptyScanResult); + + const componentSamples = samples.filter((s) => s.category === "component"); + expect(componentSamples.length).toBeLessThanOrEqual(3); + }); + + it("should return empty array when no source files exist", async () => { + const samples = await sampleSourceFiles(tempDir, emptyScanResult); + expect(samples).toEqual([]); + }); + + it("should detect path alias imports", async () => { + await mkdir(join(tempDir, "src", "utils"), { recursive: true }); + await writeFile( + join(tempDir, "src", "utils", "helpers.ts"), + `import { db } from '@/lib/db'; +export const format = (s: string) => s.trim(); +` + ); + + const samples = await sampleSourceFiles(tempDir, emptyScanResult); + + const utilSample = samples.find((s) => s.category === "util"); + expect(utilSample).toBeDefined(); + expect(utilSample!.patterns).toContain("path-aliases"); + }); +});