diff --git a/README.md b/README.md index f60fefa..e23927b 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A 24-tool MCP server for Claude Code that catches ambiguous instructions before [![npm](https://img.shields.io/npm/v/preflight-dev)](https://www.npmjs.com/package/preflight-dev) [![Node 18+](https://img.shields.io/badge/node-18%2B-brightgreen?logo=node.js&logoColor=white)](https://nodejs.org/) -[Quick Start](#quick-start) · [How It Works](#how-it-works) · [Tool Reference](#tool-reference) · [Configuration](#configuration) · [Scoring](#the-12-category-scorecard) +[Quick Start](#quick-start) · [How It Works](#how-it-works) · [Tool Reference](#tool-reference) · [Usage Examples](examples/USAGE_EXAMPLES.md) · [Configuration](#configuration) · [Scoring](#the-12-category-scorecard) @@ -406,6 +406,12 @@ This prevents the common failure mode: changing a shared type in one service and ## Configuration Reference +> **Want a ready-to-use starting point?** Copy the example configs: +> ```bash +> cp -r examples/.preflight /path/to/your/project/ +> ``` +> See [`examples/.preflight/README.md`](examples/.preflight/README.md) for details. + ### `.preflight/config.yml` Drop this in your project root. Every field is optional — defaults are sensible. diff --git a/examples/.preflight/README.md b/examples/.preflight/README.md new file mode 100644 index 0000000..b8aaf8c --- /dev/null +++ b/examples/.preflight/README.md @@ -0,0 +1,25 @@ +# `.preflight/` Example Config + +Copy this directory into your project root to configure preflight: + +```bash +cp -r examples/.preflight /path/to/your/project/ +``` + +## Files + +| File | Purpose | +|------|---------| +| `config.yml` | Main config — profile, related projects, thresholds, embeddings | +| `triage.yml` | Triage rules — which keywords trigger which classification level | +| `contracts/*.yml` | Manual contract definitions — supplement auto-extraction | + +## Quick Setup + +1. Copy the directory: `cp -r examples/.preflight ./` +2. Edit `config.yml` — set your `related_projects` paths +3. Edit `triage.yml` — add your domain-specific keywords to `always_check` +4. Optionally add contracts in `contracts/` for planned or external APIs +5. Commit `.preflight/` to your repo so your team shares the same config + +All fields are optional. Defaults work well out of the box — only customize what you need. diff --git a/examples/.preflight/config.yml b/examples/.preflight/config.yml new file mode 100644 index 0000000..0ad12e8 --- /dev/null +++ b/examples/.preflight/config.yml @@ -0,0 +1,29 @@ +# .preflight/config.yml — drop this in your project root +# All fields are optional. Defaults are sensible. +# See: https://github.com/TerminalGravity/preflight#configuration-reference + +# Profile controls overall verbosity +# "minimal" — only flag ambiguous+, skip clarification detail +# "standard" — default behavior +# "full" — maximum detail on every non-trivial prompt +profile: standard + +# Related projects for cross-service awareness +# Preflight will search these projects' indexes when your prompt +# touches shared contracts (types, routes, schemas). +related_projects: + # - path: /absolute/path/to/auth-service + # alias: auth-service + # - path: /absolute/path/to/shared-types + # alias: shared-types + +# Behavioral thresholds +thresholds: + session_stale_minutes: 30 # warn if no activity for this long + max_tool_calls_before_checkpoint: 100 # suggest checkpoint after N tool calls + correction_pattern_threshold: 3 # min corrections before forming a pattern + +# Embedding configuration +embeddings: + provider: local # "local" (Xenova, zero config) or "openai" + # openai_api_key: sk-... # only needed if provider is "openai" diff --git a/examples/.preflight/contracts/api.yml b/examples/.preflight/contracts/api.yml new file mode 100644 index 0000000..754c5da --- /dev/null +++ b/examples/.preflight/contracts/api.yml @@ -0,0 +1,47 @@ +# .preflight/contracts/api.yml — manual contract definitions +# These supplement auto-extracted contracts from your codebase. +# Manual definitions win on name conflicts with auto-extracted ones. +# +# Use this when: +# - You have contracts that aren't in code yet (planned APIs) +# - Auto-extraction misses something important +# - You want to document cross-service agreements explicitly + +- name: User + kind: interface + description: Core user object shared across services + fields: + - name: id + type: string + required: true + - name: email + type: string + required: true + - name: role + type: "'admin' | 'member' | 'viewer'" + required: true + - name: createdAt + type: Date + required: true + +- name: "POST /api/users" + kind: route + description: Create a new user account + fields: + - name: body + type: "{ email: string, role: string }" + required: true + - name: response + type: "{ user: User, token: string }" + required: true + +- name: "GET /api/users/:id" + kind: route + description: Fetch user by ID + fields: + - name: params + type: "{ id: string }" + required: true + - name: response + type: User + required: true diff --git a/examples/.preflight/triage.yml b/examples/.preflight/triage.yml new file mode 100644 index 0000000..22b05d3 --- /dev/null +++ b/examples/.preflight/triage.yml @@ -0,0 +1,38 @@ +# .preflight/triage.yml — controls the triage classification engine +# Customize which prompts get flagged, skipped, or escalated. + +rules: + # Prompts containing these words → always at least AMBIGUOUS + # Add domain terms that are too vague without context + always_check: + - rewards + - permissions + - migration + - schema + # - billing # uncomment for your domain + # - onboarding + + # Prompts containing these words → TRIVIAL (pass through immediately) + # Common low-risk commands that don't need analysis + skip: + - commit + - format + - lint + - "git status" + - "git log" + + # Prompts containing these words → CROSS-SERVICE + # Triggers search across related_projects defined in config.yml + cross_service_keywords: + - auth + - notification + - event + - webhook + # - payment + # - analytics + +# How aggressively to classify +# "relaxed" — more prompts pass as clear (faster, less interruption) +# "standard" — balanced (recommended) +# "strict" — more prompts flagged as ambiguous (thorough, more interruptions) +strictness: standard diff --git a/examples/USAGE_EXAMPLES.md b/examples/USAGE_EXAMPLES.md new file mode 100644 index 0000000..55a2100 --- /dev/null +++ b/examples/USAGE_EXAMPLES.md @@ -0,0 +1,205 @@ +# Usage Examples + +Real-world examples of preflight tools in action inside Claude Code. + +--- + +## 1. Catching a Vague Prompt with `preflight_check` + +You type this into Claude Code: + +``` +fix the login bug +``` + +Preflight intercepts and returns: + +``` +⚠️ Ambiguity detected — routing to clarify_intent + +I found 3 potential login issues: + 1. JWT token not refreshing on expiry (src/auth/jwt.ts:42) + 2. OAuth callback returning 404 after redirect (src/auth/oauth.ts:118) + 3. "Remember me" checkbox not persisting session (src/components/LoginForm.tsx:23) + +Which one? Or describe the symptoms and I'll narrow it down. +``` + +Instead of Claude guessing and editing the wrong file, you get a targeted question. One round-trip instead of three. + +--- + +## 2. Scoping Work Before Coding with `scope_work` + +``` +> scope_work "Add rate limiting to the /api/upload endpoint" + +📋 Execution Plan +───────────────── +Goal: Add rate limiting to POST /api/upload + +Files to touch: + - src/api/routes/upload.ts (add middleware) + - src/middleware/rateLimit.ts (create) + - src/config/limits.ts (add upload limits) + - tests/api/upload.test.ts (add rate limit tests) + +Dependencies: + - Check if express-rate-limit is already installed + - Review existing middleware chain in src/api/index.ts + +Risks: + - Existing upload tests may break if rate limit hits in CI + - Need to decide: per-IP or per-user limiting? + +Estimated scope: ~120 lines across 4 files +``` + +--- + +## 3. Enriching a Sub-Agent Task with `enrich_agent_task` + +You want to spawn a sub-agent for a refactor: + +``` +> enrich_agent_task "refactor the database queries to use the new ORM" + +📦 Enriched Task Brief +────────────────────── +Original: "refactor the database queries to use the new ORM" + +Context added: + - ORM: Drizzle (see drizzle.config.ts, src/db/schema.ts) + - 14 files with raw SQL queries (grep results attached) + - Existing patterns: src/db/queries/users.ts uses query builder already + - Related contracts: UserRecord, PostRecord types in src/types/db.ts + - Test coverage: 8/14 query files have corresponding tests + +Suggested task for sub-agent: + "Refactor raw SQL queries in src/db/queries/ to use Drizzle ORM. + Follow the pattern in src/db/queries/users.ts. Schema is in + src/db/schema.ts. Run `npm test -- --grep db` after each file. + 14 files total — see list below." +``` + +The sub-agent gets everything it needs instead of fumbling through the codebase. + +--- + +## 4. Resolving Vague Follow-ups with `sharpen_followup` + +After Claude fixes a bug, you say: + +``` +> do the same for the others +``` + +Without preflight, Claude guesses what "the others" means. With it: + +``` +> sharpen_followup "do the same for the others" + +🔍 Resolved "the others" from conversation context: + +You just fixed a missing null check in src/api/users.ts:34. +Similar pattern found in 4 other files: + 1. src/api/posts.ts:28 — same nullable field access + 2. src/api/comments.ts:41 — same pattern + 3. src/api/teams.ts:55 — same pattern + 4. src/api/billing.ts:19 — slightly different (optional chain exists but incomplete) + +Shall I apply the fix to all 4? +``` + +--- + +## 5. Checking Session Health with `check_session_health` + +``` +> check_session_health + +🏥 Session Health +───────────────── +⏱ Session duration: 47 min +📝 Uncommitted files: 8 (⚠️ commit soon) +🔄 Last commit: 38 min ago (⚠️ overdue) +💬 Turn count: 23 +📊 Context usage: ~62% (healthy) +🎯 Focus score: B (2 directory switches) + +Recommendation: Commit your current changes before continuing. +Run `checkpoint` to save state. +``` + +--- + +## 6. Searching Past Sessions with `search_history` + +``` +> search_history "how did we handle the Stripe webhook retry logic" + +🔍 Found 3 relevant results across 2 sessions: + +Session 2024-12-15 (confidence: 0.92) + "Implemented exponential backoff for Stripe webhook retries + in src/webhooks/stripe.ts. Uses 3 retries with 1s/4s/16s delays. + Idempotency key stored in Redis." + +Session 2024-11-28 (confidence: 0.78) + "Discussed webhook retry strategies. Decided against queue-based + approach due to infrastructure complexity. Went with in-process + retry with circuit breaker." +``` + +Never re-research decisions you already made. + +--- + +## 7. Getting a Scorecard with `generate_scorecard` + +``` +> generate_scorecard type=trend period=week + +📊 Weekly Trend — Jan 20–26, 2025 +────────────────────────────────── +Overall: B+ (78/100) ↑ from B (74) last week + +Improving: + ✅ Clarification: 82 → 89 (+7) — more file refs in prompts + ✅ Verification: 65 → 78 (+13) — running tests before done + +Declining: + ⚠️ Compaction Mgmt: 71 → 58 (-13) — 3 compactions without prior commit + ⚠️ Session Lifecycle: 80 → 72 (-8) — longer gaps between commits + +Top tip: Run `checkpoint` before long exploratory conversations +to avoid losing work on compaction. +``` + +--- + +## 8. Quick Prompt Grading with `prompt_score` + +``` +> prompt_score "update the thing" + +📝 Prompt Score: D (32/100) +─────────────────────────── +Specificity: F — no file, function, or component named +Scope: F — "the thing" is completely ambiguous +Actionability: D — "update" is vague (add? change? remove?) +Done-condition: F — no way to verify completion + +Try: "Update the user avatar upload handler in src/api/users.ts +to accept WebP format and add a test case" → A (94/100) +``` + +--- + +## Tips + +- **Start every session** with `preflight_check` on your first prompt — it'll route to the right tool automatically +- **Use `checkpoint`** every 20–30 minutes or before exploring a new direction +- **Run `search_history`** before implementing something — you may have solved it before +- **Check `session_stats`** at the end of a session to spot waste patterns +- **Set up `.preflight/config.yml`** to tune thresholds for your team (see [examples/.preflight/](/.preflight/)) diff --git a/src/index.ts b/src/index.ts index e7e9d00..9731f96 100644 --- a/src/index.ts +++ b/src/index.ts @@ -73,7 +73,7 @@ function validateRelatedProjects(): void { } // Load config and validate related projects on startup -const config = getConfig(); +getConfig(); validateRelatedProjects(); const profile = getProfile(); diff --git a/src/lib/files.ts b/src/lib/files.ts index 1cca2d4..1275c74 100644 --- a/src/lib/files.ts +++ b/src/lib/files.ts @@ -1,6 +1,6 @@ import { readFileSync, existsSync, readdirSync, statSync } from "fs"; import { join } from "path"; -import type { DocInfo, DocMeta } from "../types.js"; +import type { DocInfo } from "../types.js"; /** Single source of truth for the project directory. */ export const PROJECT_DIR = process.env.CLAUDE_PROJECT_DIR || process.cwd(); diff --git a/src/lib/git.ts b/src/lib/git.ts index a32ee3c..e8e0eb2 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -1,6 +1,5 @@ import { execFileSync } from "child_process"; import { PROJECT_DIR } from "./files.js"; -import type { RunError } from "../types.js"; /** * Run a git command safely using execFileSync (no shell injection). @@ -30,11 +29,6 @@ export function run(argsOrCmd: string | string[], opts: { timeout?: number } = { } } -/** Convenience: run a raw command string (split on spaces). Only for simple, known-safe commands. */ -function gitCmd(cmdStr: string, opts?: { timeout?: number }): string { - return run(cmdStr.split(/\s+/), opts); -} - /** Get the current branch name. */ export function getBranch(): string { return run(["branch", "--show-current"]); diff --git a/src/lib/patterns.ts b/src/lib/patterns.ts index 350e048..b41cc99 100644 --- a/src/lib/patterns.ts +++ b/src/lib/patterns.ts @@ -146,7 +146,6 @@ export function matchPatterns( patterns: CorrectionPattern[], ): CorrectionPattern[] { if (patterns.length === 0) return []; - const promptKeywords = extractKeywords(prompt); const promptLower = prompt.toLowerCase(); return patterns.filter((p) => { diff --git a/src/lib/timeline-db.ts b/src/lib/timeline-db.ts index 49b4f78..42b42f4 100644 --- a/src/lib/timeline-db.ts +++ b/src/lib/timeline-db.ts @@ -1,11 +1,11 @@ import * as lancedb from "@lancedb/lancedb"; import { randomUUID } from "node:crypto"; -import { readFile, writeFile, mkdir, stat } from "node:fs/promises"; +import { readFile, writeFile, mkdir } from "node:fs/promises"; import { createHash } from "node:crypto"; import { homedir } from "node:os"; import { join, basename, resolve } from "node:path"; -import { createEmbeddingProvider, type EmbeddingProvider, type EmbeddingConfig } from "./embeddings.js"; -import type { ProjectMeta, ProjectRegistry, SearchScope } from "../types.js"; +import { createEmbeddingProvider, type EmbeddingProvider } from "./embeddings.js"; +import type { ProjectMeta, ProjectRegistry } from "../types.js"; // --- Types --- @@ -342,7 +342,7 @@ export async function searchSemantic( _score: 1 - (result._distance || 0), }); } - } catch (error) { + } catch { // Skip projects that don't exist or have issues continue; } diff --git a/src/tools/audit-workspace.ts b/src/tools/audit-workspace.ts index d4306bd..1f59fb3 100644 --- a/src/tools/audit-workspace.ts +++ b/src/tools/audit-workspace.ts @@ -1,6 +1,6 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { run } from "../lib/git.js"; -import { readIfExists, findWorkspaceDocs } from "../lib/files.js"; +import { findWorkspaceDocs } from "../lib/files.js"; /** Extract top-level work areas from file paths generically */ function detectWorkAreas(files: string[]): Set { diff --git a/src/tools/checkpoint.ts b/src/tools/checkpoint.ts index e086f01..53d9ace 100644 --- a/src/tools/checkpoint.ts +++ b/src/tools/checkpoint.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { writeFileSync, existsSync, mkdirSync } from "fs"; -import { join, dirname } from "path"; +import { join } from "path"; import { run, getBranch, getStatus, getLastCommit, getStagedFiles } from "../lib/git.js"; import { PROJECT_DIR } from "../lib/files.js"; import { appendLog, now } from "../lib/state.js"; diff --git a/src/tools/generate-scorecard.ts b/src/tools/generate-scorecard.ts index c15576c..483ba97 100644 --- a/src/tools/generate-scorecard.ts +++ b/src/tools/generate-scorecard.ts @@ -60,7 +60,6 @@ function clamp(v: number): number { const PATH_RE = /(?:\/[\w./-]+\.\w{1,6}|\b\w+\.\w{2,6}\b)/; const FILE_EXT_RE = /\.\b(?:ts|tsx|js|jsx|py|rs|go|rb|java|c|cpp|h|css|scss|html|json|yaml|yml|toml|md|sql|sh)\b/; -const CORRECTION_PATTERNS = [/\bno\b/i, /\bwrong\b/i, /\bnot that\b/i, /\bi meant\b/i, /\bactually\b/i, /\binstead\b/i, /\bundo\b/i, /\brevert\b/i]; interface ParsedSession { id: string; @@ -419,41 +418,6 @@ function computeScorecard( }; } -// ── Markdown Output ──────────────────────────────────────────────────────── - -function toMarkdown(sc: Scorecard): string { - const lines: string[] = []; - lines.push(`# 📊 Prompt Discipline Scorecard`); - lines.push(`**Project:** ${sc.project} | **Period:** ${sc.period} (${sc.date}) | **Overall: ${sc.overallGrade} (${sc.overall}/100)**\n`); - - lines.push(`## Category Scores`); - lines.push(`| # | Category | Score | Grade |`); - lines.push(`|---|----------|-------|-------|`); - sc.categories.forEach((c, i) => { - lines.push(`| ${i + 1} | ${c.name} | ${c.score} | ${c.grade} |`); - }); - - lines.push(`\n## Highlights`); - lines.push(`- 🏆 **Best:** ${sc.highlights.best.name} (${sc.highlights.best.grade}) — ${sc.highlights.best.evidence}`); - lines.push(`- ⚠️ **Worst:** ${sc.highlights.worst.name} (${sc.highlights.worst.grade}) — ${sc.highlights.worst.evidence}`); - - lines.push(`\n## Detailed Breakdown`); - sc.categories.forEach((c, i) => { - lines.push(`\n### ${i + 1}. ${c.name} — ${c.grade} (${c.score}/100)`); - lines.push(`Evidence: ${c.evidence}`); - if (c.examples?.bad?.length) { - lines.push(`\nExamples of vague follow-ups:`); - c.examples.bad.forEach((e) => lines.push(`- ❌ "${e}"`)); - } - if (c.examples?.good?.length) { - lines.push(`\nExamples of specific follow-ups:`); - c.examples.good.forEach((e) => lines.push(`- ✅ "${e}"`)); - } - }); - - return lines.join("\n"); -} - // ── HTML / PDF Output ────────────────────────────────────────────────────── function gradeColor(grade: string): string { diff --git a/src/tools/onboard-project.ts b/src/tools/onboard-project.ts index bca91a0..69f7b29 100644 --- a/src/tools/onboard-project.ts +++ b/src/tools/onboard-project.ts @@ -5,9 +5,7 @@ import * as path from "path"; import { insertEvents, getLastIndexedTimestamp, - listIndexedProjects, getEventsTable, - registerProject, loadProjectMeta, saveProjectMeta } from "../lib/timeline-db.js"; diff --git a/src/tools/preflight-check.ts b/src/tools/preflight-check.ts index 8c9121a..653ac07 100644 --- a/src/tools/preflight-check.ts +++ b/src/tools/preflight-check.ts @@ -1,24 +1,24 @@ // Unified preflight_check — single entry point that triages and chains tools import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { triagePrompt, type TriageLevel, type TriageResult } from "../lib/triage.js"; +import { triagePrompt, type TriageLevel } from "../lib/triage.js"; import { existsSync, statSync } from "fs"; import { resolve } from "path"; import { PROJECT_DIR } from "../lib/files.js"; -import { run, getBranch, getStatus, getRecentCommits, getDiffFiles, getStagedFiles } from "../lib/git.js"; +import { getBranch, getStatus, getRecentCommits } from "../lib/git.js"; import { now } from "../lib/state.js"; import { findWorkspaceDocs } from "../lib/files.js"; import { getConfig } from "../lib/config.js"; import { searchSemantic } from "../lib/timeline-db.js"; -import { basename, join } from "path"; -import { loadPatterns, matchPatterns, formatPatternMatches } from "../lib/patterns.js"; +import { basename } from "path"; +import { loadPatterns, matchPatterns } from "../lib/patterns.js"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Extract file paths from prompt text */ -function extractFilePaths(prompt: string): string[] { +export function extractFilePaths(prompt: string): string[] { const matches = prompt.match(/[\w\-./\\]+\.\w{1,6}/g) || []; return [...new Set(matches)]; } @@ -117,7 +117,7 @@ function buildClarifySection(prompt: string): string[] { } /** Build scope section for multi-step */ -function buildScopeSection(prompt: string): string[] { +export function buildScopeSection(prompt: string): string[] { const sections: string[] = []; const filePaths = extractFilePaths(prompt); const fileVerification = verifyFiles(filePaths); @@ -136,7 +136,7 @@ function buildScopeSection(prompt: string): string[] { } /** Build sequence section for multi-step */ -function buildSequenceSection(prompt: string): string[] { +export function buildSequenceSection(prompt: string): string[] { // Split prompt into sub-tasks const subtasks: string[] = []; diff --git a/src/tools/scan-sessions.ts b/src/tools/scan-sessions.ts index 3d3ecb5..707f7b2 100644 --- a/src/tools/scan-sessions.ts +++ b/src/tools/scan-sessions.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import * as fs from "fs"; -import * as path from "path"; import { findSessionDirs, findSessionFiles } from "../lib/session-parser.js"; interface SessionInfo { diff --git a/src/tools/scope-work.ts b/src/tools/scope-work.ts index 9b5d971..49db5dd 100644 --- a/src/tools/scope-work.ts +++ b/src/tools/scope-work.ts @@ -1,13 +1,13 @@ // CATEGORY 1: scope_work — Plans import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { run, getBranch, getRecentCommits, getStatus } from "../lib/git.js"; +import { run, getBranch, getRecentCommits } from "../lib/git.js"; import { readIfExists, findWorkspaceDocs, PROJECT_DIR } from "../lib/files.js"; import { searchSemantic } from "../lib/timeline-db.js"; import { getRelatedProjects } from "../lib/config.js"; import { now } from "../lib/state.js"; import { existsSync } from "fs"; -import { join, normalize, resolve, basename } from "path"; +import { join, resolve, basename } from "path"; import { loadAllContracts, searchContracts, formatContracts } from "../lib/contracts.js"; const STOP_WORDS = new Set([ diff --git a/src/tools/sequence-tasks.ts b/src/tools/sequence-tasks.ts index 22dea23..bc4f36e 100644 --- a/src/tools/sequence-tasks.ts +++ b/src/tools/sequence-tasks.ts @@ -4,8 +4,7 @@ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { run } from "../lib/git.js"; import { now } from "../lib/state.js"; import { PROJECT_DIR } from "../lib/files.js"; -import { existsSync } from "fs"; -import { join, resolve } from "path"; +import { resolve } from "path"; type Cat = "schema" | "config" | "api" | "ui" | "test" | "other"; diff --git a/src/tools/token-audit.ts b/src/tools/token-audit.ts index b7aad2c..bb34277 100644 --- a/src/tools/token-audit.ts +++ b/src/tools/token-audit.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { run } from "../lib/git.js"; import { readIfExists, findWorkspaceDocs, PROJECT_DIR } from "../lib/files.js"; -import { loadState, saveState, now, STATE_DIR } from "../lib/state.js"; +import { saveState, now, STATE_DIR } from "../lib/state.js"; import { readFileSync, existsSync, statSync } from "fs"; import { join } from "path"; diff --git a/tests/tools/preflight-check.test.ts b/tests/tools/preflight-check.test.ts new file mode 100644 index 0000000..3485464 --- /dev/null +++ b/tests/tools/preflight-check.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest"; +import { + extractFilePaths, + buildScopeSection, + buildSequenceSection, +} from "../../src/tools/preflight-check.js"; + +describe("extractFilePaths", () => { + it("extracts typical source file paths", () => { + const result = extractFilePaths("update src/lib/triage.ts and src/tools/audit-workspace.ts"); + expect(result).toContain("src/lib/triage.ts"); + expect(result).toContain("src/tools/audit-workspace.ts"); + }); + + it("deduplicates repeated paths", () => { + const result = extractFilePaths("fix foo.ts then test foo.ts again"); + expect(result.filter((p) => p === "foo.ts")).toHaveLength(1); + }); + + it("returns empty array for no file references", () => { + expect(extractFilePaths("fix the auth bug")).toEqual([]); + }); + + it("handles dotfiles and nested paths", () => { + const result = extractFilePaths("edit .env and config/settings.json"); + expect(result).toContain("config/settings.json"); + }); + + it("handles various extensions", () => { + const result = extractFilePaths("check index.html style.css app.js data.json"); + expect(result).toHaveLength(4); + }); +}); + +describe("buildScopeSection", () => { + it("reports SMALL scope for single-file prompts", () => { + const sections = buildScopeSection("fix a typo in README.md"); + const scopeLine = sections.find((s) => s.startsWith("### Scope:")); + expect(scopeLine).toContain("SMALL"); + }); + + it("reports MEDIUM scope for multi-file prompts", () => { + const sections = buildScopeSection("update src/a.ts and src/b.ts and src/c.ts"); + const scopeLine = sections.find((s) => s.startsWith("### Scope:")); + // 3 files in same dir = MEDIUM (not LARGE since only 1 dir prefix) + expect(scopeLine).toMatch(/SMALL|MEDIUM/); + }); + + it("reports LARGE scope for many files across directories", () => { + const sections = buildScopeSection( + "refactor src/lib/a.ts src/tools/b.ts tests/c.ts config/d.json" + ); + const scopeLine = sections.find((s) => s.startsWith("### Scope:")); + expect(scopeLine).toContain("LARGE"); + }); +}); + +describe("buildSequenceSection", () => { + it("splits multi-step prompts on 'then'", () => { + const sections = buildSequenceSection("add the endpoint then write tests then deploy"); + const steps = sections.filter((s) => /^\d+\./.test(s)); + expect(steps.length).toBeGreaterThanOrEqual(3); + }); + + it("assigns HIGH risk to schema/migration steps", () => { + const sections = buildSequenceSection("run the database migration then update the API"); + const migrationStep = sections.find((s) => /migration/i.test(s)); + expect(migrationStep).toContain("HIGH"); + }); + + it("assigns MEDIUM risk to API steps", () => { + const sections = buildSequenceSection("update the API endpoint"); + const apiStep = sections.find((s) => /API/i.test(s)); + expect(apiStep).toContain("MEDIUM"); + }); + + it("assigns LOW risk to simple steps", () => { + const sections = buildSequenceSection("update the readme then fix the typo"); + const steps = sections.filter((s) => /^\d+\./.test(s)); + expect(steps.some((s) => s.includes("LOW"))).toBe(true); + }); + + it("includes checkpoint reminders", () => { + const sections = buildSequenceSection("do stuff then more stuff"); + expect(sections.some((s) => /checkpoint/i.test(s))).toBe(true); + }); +});