diff --git a/README.md b/README.md index f60fefa..15b50f7 100644 --- a/README.md +++ b/README.md @@ -500,6 +500,8 @@ Manual contract definitions that supplement auto-extraction: Environment variables are **fallbacks** — `.preflight/` config takes precedence when present. +> 💡 **Ready-to-use examples:** Copy [`examples/.preflight/`](examples/.preflight/) into your project root for a working starter config with detailed comments. + --- ## Embedding Providers diff --git a/examples/.preflight/config.yml b/examples/.preflight/config.yml new file mode 100644 index 0000000..f59170f --- /dev/null +++ b/examples/.preflight/config.yml @@ -0,0 +1,35 @@ +# .preflight/config.yml — Drop this in your project root +# +# This is an example config for a typical Next.js + microservices setup. +# Every field is optional — preflight works with sensible defaults out of the box. +# Commit this to your repo so the whole team gets the same preflight behavior. + +# Profile controls how much detail preflight returns. +# "minimal" — only flags ambiguous+ prompts, skips clarification detail +# "standard" — balanced (default) +# "full" — maximum detail on every non-trivial prompt +profile: standard + +# Related projects for cross-service awareness. +# Preflight will search these for shared types, routes, and contracts +# so it can warn you when a change might break a consumer. +related_projects: + - path: /Users/you/code/auth-service + alias: auth + - path: /Users/you/code/billing-api + alias: billing + - path: /Users/you/code/shared-types + alias: types + +# Behavioral thresholds — tune these to your workflow +thresholds: + session_stale_minutes: 30 # Warn if no activity for this long + max_tool_calls_before_checkpoint: 100 # Suggest a checkpoint after N tool calls + correction_pattern_threshold: 3 # Min corrections before flagging a pattern + +# Embedding provider for semantic search over session history. +# "local" uses Xenova transformers (no API key needed, runs on CPU). +# "openai" uses text-embedding-3-small (faster, needs OPENAI_API_KEY). +embeddings: + provider: local + # openai_api_key: sk-... # Uncomment if using openai provider diff --git a/examples/.preflight/contracts/api.yml b/examples/.preflight/contracts/api.yml new file mode 100644 index 0000000..512543f --- /dev/null +++ b/examples/.preflight/contracts/api.yml @@ -0,0 +1,58 @@ +# .preflight/contracts/api.yml — Manual contract definitions +# +# Define shared types and interfaces that preflight should know about. +# These supplement auto-extracted contracts from your codebase. +# Manual definitions win on name conflicts with auto-extracted ones. +# +# Why manual contracts? +# - Document cross-service interfaces that live in docs, not code +# - Define contracts for external APIs your services consume +# - Pin down types that are implicit (e.g., event payloads) + +- name: User + kind: interface + description: Core user model shared across all services + fields: + - name: id + type: string + required: true + - name: email + type: string + required: true + - name: tier + type: "'free' | 'pro' | 'enterprise'" + required: true + - name: createdAt + type: Date + required: true + +- name: AuthToken + kind: interface + description: JWT payload structure from auth-service + fields: + - name: userId + type: string + required: true + - name: permissions + type: string[] + required: true + - name: expiresAt + type: number + required: true + +- name: WebhookPayload + kind: interface + description: Standard webhook envelope for inter-service events + fields: + - name: event + type: string + required: true + - name: timestamp + type: string + required: true + - name: data + type: Record + required: true + - name: source + type: string + required: true diff --git a/examples/.preflight/triage.yml b/examples/.preflight/triage.yml new file mode 100644 index 0000000..b3d394e --- /dev/null +++ b/examples/.preflight/triage.yml @@ -0,0 +1,45 @@ +# .preflight/triage.yml — Controls how preflight classifies your prompts +# +# The triage engine routes prompts into categories: +# TRIVIAL → pass through (commit, format, lint) +# CLEAR → well-specified, no intervention needed +# AMBIGUOUS → needs clarification before proceeding +# MULTI-STEP → complex task, preflight suggests a plan +# CROSS-SERVICE → touches multiple projects, pulls in contracts +# +# Customize the keywords below to match your domain. + +rules: + # Prompts containing these words are always flagged as AMBIGUOUS. + # Add domain-specific terms that tend to produce vague prompts. + always_check: + - rewards + - permissions + - migration + - schema + - pricing # example: your billing domain + - onboarding # example: multi-step user flows + + # Prompts containing these words skip checks entirely (TRIVIAL). + # These are safe, mechanical tasks that don't need guardrails. + skip: + - commit + - format + - lint + - prettier + - "git push" + + # Prompts containing these words trigger CROSS-SERVICE classification. + # Preflight will search related_projects for relevant types and routes. + cross_service_keywords: + - auth + - notification + - event + - webhook + - billing # matches the related_project alias + +# How aggressively to classify prompts. +# "relaxed" — more prompts pass as clear (experienced users) +# "standard" — balanced (default) +# "strict" — more prompts flagged as ambiguous (new teams, complex codebases) +strictness: standard diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..778f15d --- /dev/null +++ b/examples/README.md @@ -0,0 +1,35 @@ +# Examples + +## `.preflight/` Config Directory + +The `.preflight/` directory contains example configuration files you can copy into your project root: + +``` +.preflight/ +├── config.yml # Main config — profile, related projects, thresholds +├── triage.yml # Triage rules — keywords, strictness +└── contracts/ + └── api.yml # Manual contract definitions for cross-service types +``` + +### Quick setup + +```bash +# From your project root: +cp -r /path/to/preflight/examples/.preflight .preflight + +# Edit paths in config.yml to match your setup: +$EDITOR .preflight/config.yml +``` + +Then commit `.preflight/` to your repo — your whole team gets the same preflight behavior. + +### What each file does + +| File | Purpose | Required? | +|------|---------|-----------| +| `config.yml` | Profile, related projects, thresholds, embedding config | No — sensible defaults | +| `triage.yml` | Keyword rules for prompt classification | No — sensible defaults | +| `contracts/*.yml` | Manual type/interface definitions for cross-service awareness | No — auto-extraction works without it | + +All files are optional. Preflight works out of the box with zero config — these files let you tune it to your codebase. diff --git a/tests/lib/files.test.ts b/tests/lib/files.test.ts new file mode 100644 index 0000000..e0b6e0e --- /dev/null +++ b/tests/lib/files.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, afterAll, beforeAll } from "vitest"; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync, readFileSync, existsSync, readdirSync, statSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +/** + * Since PROJECT_DIR is a module-level constant in files.ts (set at import time), + * we re-implement the functions here against a temp dir to test the logic properly. + * This tests the actual algorithms without fighting module caching. + */ + +let tempDir: string; + +beforeAll(() => { + tempDir = mkdtempSync(join(tmpdir(), "preflight-files-test-")); +}); + +afterAll(() => { + rmSync(tempDir, { recursive: true, force: true }); +}); + +// Re-implement readIfExists logic to test against tempDir +function readIfExists(baseDir: string, relPath: string, maxLines = 50): string | null { + const full = join(baseDir, relPath); + if (!existsSync(full)) return null; + try { + const buf = readFileSync(full); + if (buf.subarray(0, 8192).includes(0)) return null; + const lines = buf.toString("utf-8").split("\n"); + return lines.slice(0, maxLines).join("\n"); + } catch { + return null; + } +} + +// Actually, let's just import and test the real module by setting env before anything +// The trick: we need to ensure no other test imports files.js first. +// Better approach: test via the actual exported functions with a subprocess or +// just test the real module and accept it uses cwd. + +// Simplest correct approach: test the actual module's exported functions. +// For readIfExists, we create files relative to PROJECT_DIR (which is cwd in test). +// For findWorkspaceDocs, we'd need a .claude/ dir in cwd which is messy. +// Let's use a pragmatic approach: test readIfExists with real files in a subdir, +// and test findWorkspaceDocs behavior by creating a temp .claude/ and cleaning up. + +import { readIfExists as realReadIfExists, findWorkspaceDocs, PROJECT_DIR } from "../../src/lib/files.js"; + +describe("readIfExists()", () => { + const testDir = ".__test_files_tmp__"; + + beforeAll(() => { + mkdirSync(join(PROJECT_DIR, testDir), { recursive: true }); + }); + + afterAll(() => { + rmSync(join(PROJECT_DIR, testDir), { recursive: true, force: true }); + }); + + it("returns null for non-existent file", () => { + expect(realReadIfExists(join(testDir, "nope.txt"))).toBeNull(); + }); + + it("reads a text file", () => { + writeFileSync(join(PROJECT_DIR, testDir, "hello.md"), "# Hello\nWorld"); + expect(realReadIfExists(join(testDir, "hello.md"))).toBe("# Hello\nWorld"); + }); + + it("limits lines returned", () => { + const lines = Array.from({ length: 100 }, (_, i) => `line ${i}`).join("\n"); + writeFileSync(join(PROJECT_DIR, testDir, "big.txt"), lines); + const result = realReadIfExists(join(testDir, "big.txt"), 3); + expect(result).toBe("line 0\nline 1\nline 2"); + }); + + it("uses default of 50 lines", () => { + const lines = Array.from({ length: 100 }, (_, i) => `L${i}`).join("\n"); + writeFileSync(join(PROJECT_DIR, testDir, "many.txt"), lines); + const result = realReadIfExists(join(testDir, "many.txt")); + expect(result?.split("\n").length).toBe(50); + }); + + it("rejects binary files with null bytes", () => { + const buf = Buffer.from([0x48, 0x65, 0x6c, 0x00, 0x6f]); + writeFileSync(join(PROJECT_DIR, testDir, "bin.dat"), buf); + expect(realReadIfExists(join(testDir, "bin.dat"))).toBeNull(); + }); + + it("allows text files without null bytes", () => { + writeFileSync(join(PROJECT_DIR, testDir, "clean.txt"), "no nulls"); + expect(realReadIfExists(join(testDir, "clean.txt"))).toBe("no nulls"); + }); +}); + +describe("findWorkspaceDocs()", () => { + // These tests work with the real .claude/ dir if it exists + // We test the return type and behavior + + it("returns an object", () => { + const result = findWorkspaceDocs(); + expect(typeof result).toBe("object"); + }); + + it("metadataOnly returns empty content strings", () => { + const docs = findWorkspaceDocs({ metadataOnly: true }); + for (const [, doc] of Object.entries(docs)) { + expect(doc.content).toBe(""); + } + }); + + it("regular mode returns content with mtime and size", () => { + const docs = findWorkspaceDocs(); + for (const [, doc] of Object.entries(docs)) { + expect(doc).toHaveProperty("content"); + expect(doc).toHaveProperty("mtime"); + expect(doc).toHaveProperty("size"); + expect(typeof doc.content).toBe("string"); + expect(typeof doc.size).toBe("number"); + } + }); + + it("content is limited to 40 lines per file", () => { + const docs = findWorkspaceDocs(); + for (const [, doc] of Object.entries(docs)) { + expect(doc.content.split("\n").length).toBeLessThanOrEqual(40); + } + }); +}); + +describe("PROJECT_DIR", () => { + it("is a string", () => { + expect(typeof PROJECT_DIR).toBe("string"); + }); + + it("falls back to cwd when CLAUDE_PROJECT_DIR is not set", () => { + // In test context, it should be cwd or the env var + expect(PROJECT_DIR.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/lib/git.test.ts b/tests/lib/git.test.ts new file mode 100644 index 0000000..c4d294b --- /dev/null +++ b/tests/lib/git.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { execFileSync } from "child_process"; + +vi.mock("child_process", () => ({ + execFileSync: vi.fn(), +})); + +vi.mock("../../src/lib/files.js", () => ({ + PROJECT_DIR: "/fake/project", +})); + +const mockedExec = vi.mocked(execFileSync); + +// Import after mocks are set up +import { + run, + getBranch, + getStatus, + getRecentCommits, + getLastCommit, + getLastCommitTime, + getDiffFiles, + getStagedFiles, + getDiffStat, +} from "../../src/lib/git.js"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("run()", () => { + it("accepts an array of args", () => { + mockedExec.mockReturnValue("ok\n"); + expect(run(["status", "--short"])).toBe("ok"); + expect(mockedExec).toHaveBeenCalledWith( + "git", + ["status", "--short"], + expect.objectContaining({ cwd: "/fake/project" }) + ); + }); + + it("splits string args on whitespace", () => { + mockedExec.mockReturnValue("main\n"); + expect(run("branch --show-current")).toBe("main"); + expect(mockedExec).toHaveBeenCalledWith( + "git", + ["branch", "--show-current"], + expect.anything() + ); + }); + + it("trims output", () => { + mockedExec.mockReturnValue(" hello world \n"); + expect(run(["log"])).toBe("hello world"); + }); + + it("returns timeout message when process is killed", () => { + const err: any = new Error("killed"); + err.killed = true; + mockedExec.mockImplementation(() => { throw err; }); + expect(run(["log"], { timeout: 5000 })).toBe("[timed out after 5000ms]"); + }); + + it("returns timeout message on SIGTERM", () => { + const err: any = new Error("signal"); + err.signal = "SIGTERM"; + mockedExec.mockImplementation(() => { throw err; }); + expect(run(["log"])).toBe("[timed out after 10000ms]"); + }); + + it("returns stderr on command failure", () => { + const err: any = new Error("fail"); + err.stdout = ""; + err.stderr = "fatal: not a git repo\n"; + err.status = 128; + mockedExec.mockImplementation(() => { throw err; }); + expect(run(["status"])).toBe("fatal: not a git repo"); + }); + + it("returns stdout from error if stderr is empty", () => { + const err: any = new Error("fail"); + err.stdout = "partial output\n"; + err.stderr = ""; + err.status = 1; + mockedExec.mockImplementation(() => { throw err; }); + expect(run(["diff"])).toBe("partial output"); + }); + + it("returns ENOENT message when git is not found", () => { + const err: any = new Error("not found"); + err.code = "ENOENT"; + err.stdout = ""; + err.stderr = ""; + mockedExec.mockImplementation(() => { throw err; }); + expect(run(["status"])).toBe("[git not found]"); + }); + + it("returns generic failure message as fallback", () => { + const err: any = new Error("unknown"); + err.stdout = ""; + err.stderr = ""; + err.status = 2; + mockedExec.mockImplementation(() => { throw err; }); + expect(run(["foo", "bar"])).toBe("[command failed: git foo bar (exit 2)]"); + }); + + it("handles undefined exit status", () => { + const err: any = new Error("unknown"); + err.stdout = ""; + err.stderr = ""; + mockedExec.mockImplementation(() => { throw err; }); + expect(run(["x"])).toBe("[command failed: git x (exit ?)]"); + }); + + it("uses default 10s timeout", () => { + mockedExec.mockReturnValue(""); + run(["status"]); + expect(mockedExec).toHaveBeenCalledWith( + "git", + ["status"], + expect.objectContaining({ timeout: 10000 }) + ); + }); + + it("uses custom timeout", () => { + mockedExec.mockReturnValue(""); + run(["log"], { timeout: 3000 }); + expect(mockedExec).toHaveBeenCalledWith( + "git", + ["log"], + expect.objectContaining({ timeout: 3000 }) + ); + }); +}); + +describe("getBranch()", () => { + it("returns current branch name", () => { + mockedExec.mockReturnValue("feature/cool\n"); + expect(getBranch()).toBe("feature/cool"); + }); +}); + +describe("getStatus()", () => { + it("returns short status", () => { + mockedExec.mockReturnValue(" M src/index.ts\n?? new.ts\n"); + expect(getStatus()).toBe("M src/index.ts\n?? new.ts"); + }); +}); + +describe("getRecentCommits()", () => { + it("defaults to 5 commits", () => { + mockedExec.mockReturnValue("abc123 first\ndef456 second\n"); + getRecentCommits(); + expect(mockedExec).toHaveBeenCalledWith( + "git", + ["log", "--oneline", "-5"], + expect.anything() + ); + }); + + it("accepts custom count", () => { + mockedExec.mockReturnValue("abc123 first\n"); + getRecentCommits(10); + expect(mockedExec).toHaveBeenCalledWith( + "git", + ["log", "--oneline", "-10"], + expect.anything() + ); + }); +}); + +describe("getLastCommit()", () => { + it("returns single oneline commit", () => { + mockedExec.mockReturnValue("abc123 fix bug\n"); + expect(getLastCommit()).toBe("abc123 fix bug"); + }); +}); + +describe("getLastCommitTime()", () => { + it("returns formatted timestamp", () => { + mockedExec.mockReturnValue("2026-03-10 13:00:00 -0700\n"); + expect(getLastCommitTime()).toBe("2026-03-10 13:00:00 -0700"); + }); +}); + +describe("getDiffFiles()", () => { + it("returns diff files with default ref", () => { + mockedExec.mockReturnValue("src/a.ts\nsrc/b.ts\n"); + expect(getDiffFiles()).toBe("src/a.ts\nsrc/b.ts"); + expect(mockedExec).toHaveBeenCalledWith( + "git", + ["diff", "--name-only", "HEAD~3"], + expect.anything() + ); + }); + + it("accepts custom ref", () => { + mockedExec.mockReturnValue("file.ts\n"); + getDiffFiles("main"); + expect(mockedExec).toHaveBeenCalledWith( + "git", + ["diff", "--name-only", "main"], + expect.anything() + ); + }); + + it("falls back to HEAD~1 on error", () => { + mockedExec + .mockReturnValueOnce("[command failed: git diff (exit 1)]" as any) + .mockReturnValueOnce("fallback.ts\n"); + expect(getDiffFiles()).toBe("fallback.ts"); + }); + + it("returns 'no commits' when both attempts fail", () => { + mockedExec + .mockReturnValueOnce("[error]" as any) + .mockReturnValueOnce("[error]" as any); + expect(getDiffFiles()).toBe("no commits"); + }); +}); + +describe("getStagedFiles()", () => { + it("returns staged file list", () => { + mockedExec.mockReturnValue("staged.ts\n"); + expect(getStagedFiles()).toBe("staged.ts"); + }); +}); + +describe("getDiffStat()", () => { + it("returns diff stat with default ref", () => { + mockedExec.mockReturnValue(" 2 files changed, 10 insertions(+)\n"); + expect(getDiffStat()).toBe("2 files changed, 10 insertions(+)"); + expect(mockedExec).toHaveBeenCalledWith( + "git", + ["diff", "HEAD~5", "--stat"], + expect.anything() + ); + }); + + it("falls back to HEAD~3 on error", () => { + mockedExec + .mockReturnValueOnce("[command failed]" as any) + .mockReturnValueOnce(" 1 file changed\n"); + expect(getDiffStat()).toBe("1 file changed"); + }); + + it("returns fallback message when both fail", () => { + mockedExec + .mockReturnValueOnce("[error]" as any) + .mockReturnValueOnce("[error]" as any); + expect(getDiffStat()).toBe("no diff stats available"); + }); +});