From bb19c4b57a55ec4e8fd681632d7f99866e122dc7 Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Wed, 4 Mar 2026 08:44:37 -0700 Subject: [PATCH 1/2] test: add comprehensive config module tests Add 12 tests covering: - Default config when no .preflight/ dir or env vars - Env var overrides (profile, related projects, embeddings) - Invalid env var values ignored gracefully - .preflight/config.yml loading and merging with defaults - .preflight/triage.yml loading - .preflight/ dir takes precedence over env vars - Malformed YAML handled gracefully (no crash) - hasPreflightConfig() detection - getRelatedProjects() backward compat helper Brings test count from 43 to 55. --- tests/lib/config.test.ts | 158 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 tests/lib/config.test.ts diff --git a/tests/lib/config.test.ts b/tests/lib/config.test.ts new file mode 100644 index 0000000..c435857 --- /dev/null +++ b/tests/lib/config.test.ts @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { join } from "path"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs"; +import { tmpdir } from "os"; + +// We need to control PROJECT_DIR and reset the config singleton between tests. +// The config module reads from PROJECT_DIR at load time, so we mock files.ts. + +let tempDir: string; + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "preflight-config-test-")); + vi.stubEnv("CLAUDE_PROJECT_DIR", tempDir); + // Reset the cached config singleton by re-importing + vi.resetModules(); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + rmSync(tempDir, { recursive: true, force: true }); +}); + +async function loadConfig() { + const mod = await import("../../src/lib/config.js"); + return mod; +} + +describe("config", () => { + it("returns default config when no .preflight/ exists and no env vars", async () => { + const { getConfig } = await loadConfig(); + const config = getConfig(); + expect(config.profile).toBe("standard"); + expect(config.related_projects).toEqual([]); + expect(config.thresholds.session_stale_minutes).toBe(30); + expect(config.embeddings.provider).toBe("local"); + expect(config.triage.strictness).toBe("standard"); + expect(config.triage.rules.always_check).toContain("migration"); + }); + + it("reads profile from env when no .preflight/ dir", async () => { + vi.stubEnv("PROMPT_DISCIPLINE_PROFILE", "minimal"); + const { getConfig } = await loadConfig(); + expect(getConfig().profile).toBe("minimal"); + }); + + it("reads related projects from env when no .preflight/ dir", async () => { + vi.stubEnv("PREFLIGHT_RELATED", "/tmp/foo, /tmp/bar"); + const { getConfig } = await loadConfig(); + const projects = getConfig().related_projects; + expect(projects).toHaveLength(2); + expect(projects[0]).toEqual({ path: "/tmp/foo", alias: "foo" }); + expect(projects[1]).toEqual({ path: "/tmp/bar", alias: "bar" }); + }); + + it("reads embedding provider from env", async () => { + vi.stubEnv("EMBEDDING_PROVIDER", "openai"); + const { getConfig } = await loadConfig(); + expect(getConfig().embeddings.provider).toBe("openai"); + }); + + it("ignores invalid profile env values", async () => { + vi.stubEnv("PROMPT_DISCIPLINE_PROFILE", "turbo"); + const { getConfig } = await loadConfig(); + expect(getConfig().profile).toBe("standard"); // default + }); + + it("loads config from .preflight/config.yml", async () => { + const preflightDir = join(tempDir, ".preflight"); + mkdirSync(preflightDir); + writeFileSync( + join(preflightDir, "config.yml"), + `profile: full +thresholds: + session_stale_minutes: 60 +related_projects: + - path: /opt/api + alias: api-service +embeddings: + provider: openai +` + ); + const { getConfig } = await loadConfig(); + const config = getConfig(); + expect(config.profile).toBe("full"); + expect(config.thresholds.session_stale_minutes).toBe(60); + // Other thresholds should keep defaults + expect(config.thresholds.max_tool_calls_before_checkpoint).toBe(100); + expect(config.related_projects).toEqual([{ path: "/opt/api", alias: "api-service" }]); + expect(config.embeddings.provider).toBe("openai"); + }); + + it("loads triage rules from .preflight/triage.yml", async () => { + const preflightDir = join(tempDir, ".preflight"); + mkdirSync(preflightDir); + writeFileSync( + join(preflightDir, "triage.yml"), + `strictness: strict +rules: + always_check: + - payments + - auth + skip: + - format +` + ); + const { getConfig } = await loadConfig(); + const config = getConfig(); + expect(config.triage.strictness).toBe("strict"); + expect(config.triage.rules.always_check).toEqual(["payments", "auth"]); + expect(config.triage.rules.skip).toEqual(["format"]); + }); + + it("ignores env vars when .preflight/ directory exists", async () => { + const preflightDir = join(tempDir, ".preflight"); + mkdirSync(preflightDir); + writeFileSync(join(preflightDir, "config.yml"), "profile: minimal\n"); + vi.stubEnv("PROMPT_DISCIPLINE_PROFILE", "full"); + const { getConfig } = await loadConfig(); + // .preflight/ takes precedence, env var ignored + expect(getConfig().profile).toBe("minimal"); + }); + + it("handles malformed YAML gracefully", async () => { + const preflightDir = join(tempDir, ".preflight"); + mkdirSync(preflightDir); + writeFileSync(join(preflightDir, "config.yml"), "{{invalid yaml"); + const { getConfig } = await loadConfig(); + // Should fall back to defaults without crashing + expect(getConfig().profile).toBe("standard"); + }); + + it("hasPreflightConfig returns true when .preflight/ exists", async () => { + mkdirSync(join(tempDir, ".preflight")); + const { hasPreflightConfig } = await loadConfig(); + expect(hasPreflightConfig()).toBe(true); + }); + + it("hasPreflightConfig returns false when .preflight/ does not exist", async () => { + const { hasPreflightConfig } = await loadConfig(); + expect(hasPreflightConfig()).toBe(false); + }); + + it("getRelatedProjects returns path array for backward compat", async () => { + const preflightDir = join(tempDir, ".preflight"); + mkdirSync(preflightDir); + writeFileSync( + join(preflightDir, "config.yml"), + `related_projects: + - path: /a + alias: a + - path: /b + alias: b +` + ); + const { getRelatedProjects } = await loadConfig(); + expect(getRelatedProjects()).toEqual(["/a", "/b"]); + }); +}); From 52e2fc70925a62496251eb3d5a53fe2a2d9bb664 Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Wed, 4 Mar 2026 08:55:17 -0700 Subject: [PATCH 2/2] docs: add example .preflight/ config directory with annotated configs Adds examples/.preflight/ with ready-to-copy config files: - config.yml: profile, related projects, thresholds, embeddings - triage.yml: always_check/skip/cross_service keywords, strictness - contracts/api.yml: manual contract definition examples Also adds a 'Quick Setup' section to README pointing to the examples. --- README.md | 10 ++++++ examples/.preflight/config.yml | 41 +++++++++++++++++++++ examples/.preflight/contracts/api.yml | 52 +++++++++++++++++++++++++++ examples/.preflight/triage.yml | 45 +++++++++++++++++++++++ 4 files changed, 148 insertions(+) create mode 100644 examples/.preflight/config.yml create mode 100644 examples/.preflight/contracts/api.yml create mode 100644 examples/.preflight/triage.yml diff --git a/README.md b/README.md index f60fefa..3a68a09 100644 --- a/README.md +++ b/README.md @@ -406,6 +406,16 @@ This prevents the common failure mode: changing a shared type in one service and ## Configuration Reference +### Quick Setup + +Copy the example config directory into your project: + +```bash +cp -r examples/.preflight /path/to/your/project/ +``` + +Then edit the files to match your project. See [`examples/.preflight/`](examples/.preflight/) for annotated examples of all three config files. + ### `.preflight/config.yml` Drop this in your project root. Every field is optional — defaults are sensible. diff --git a/examples/.preflight/config.yml b/examples/.preflight/config.yml new file mode 100644 index 0000000..8d5c2a1 --- /dev/null +++ b/examples/.preflight/config.yml @@ -0,0 +1,41 @@ +# .preflight/config.yml — Drop this in your project root +# +# Copy this entire .preflight/ directory into your project: +# cp -r examples/.preflight /path/to/your/project/ +# +# Every field is optional. Defaults are sensible — only override what you need. + +# Profile controls how much detail preflight adds to responses. +# "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. +# When your prompt mentions something from a related service, +# preflight searches that project's indexed history and contracts. +related_projects: + - path: /absolute/path/to/api-service + alias: api + - path: /absolute/path/to/auth-service + alias: auth + # - path: /absolute/path/to/shared-types + # alias: shared-types + +# Behavioral thresholds — tune these to your workflow +thresholds: + # Warn if no activity for this many minutes + session_stale_minutes: 30 + + # Suggest a checkpoint after this many tool calls + max_tool_calls_before_checkpoint: 100 + + # Minimum corrections before forming a learnable pattern + correction_pattern_threshold: 3 + +# Embedding configuration +embeddings: + # "local" uses Xenova/all-MiniLM-L6-v2 (~90MB download, runs offline) + # "openai" uses text-embedding-ada-002 (faster, requires API key) + provider: local + # 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..f1a8a22 --- /dev/null +++ b/examples/.preflight/contracts/api.yml @@ -0,0 +1,52 @@ +# .preflight/contracts/api.yml — Manual contract definitions +# +# Use this to define API contracts that auto-extraction might miss, +# or to document contracts from external services your project consumes. +# +# Manual definitions take precedence over auto-extracted ones when +# names conflict. + +- name: User + kind: interface + description: Core user object returned by the API + 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: CreateUserRequest + kind: interface + description: Payload for POST /api/users + fields: + - name: email + type: string + required: true + - name: role + type: "'admin' | 'member' | 'viewer'" + required: false + description: Defaults to 'member' + +# Example: document an external webhook your app receives +- name: StripeWebhookEvent + kind: interface + description: Incoming Stripe webhook payload (subset we care about) + fields: + - name: type + type: string + required: true + description: "e.g. 'checkout.session.completed'" + - name: data.object.id + type: string + required: true + - name: data.object.customer + type: string + required: true diff --git a/examples/.preflight/triage.yml b/examples/.preflight/triage.yml new file mode 100644 index 0000000..9667e60 --- /dev/null +++ b/examples/.preflight/triage.yml @@ -0,0 +1,45 @@ +# .preflight/triage.yml — Controls the triage classification engine +# +# This is where you teach preflight about YOUR project's domain. +# Add keywords that matter to your codebase so triage routes correctly. + +rules: + # Prompts containing these are ALWAYS flagged as ambiguous (at minimum). + # Add domain terms that are too important to let vague prompts slip through. + always_check: + - rewards + - permissions + - migration + - schema + - billing + # - payments + # - onboarding + + # Prompts containing these pass through as TRIVIAL (no checks). + # These are safe, mechanical commands that don't need disambiguation. + skip: + - commit + - format + - lint + - "save this" + - "push it" + + # Prompts containing these escalate to CROSS-SERVICE triage. + # Add keywords for services your project talks to. + cross_service_keywords: + - auth + - notification + - event + - webhook + # - analytics + # - stripe + # - sendgrid + +# How aggressively to classify prompts. +# "relaxed" — more prompts pass as clear (fewer interruptions) +# "standard" — balanced (default) +# "strict" — more prompts flagged as ambiguous (catches more mistakes) +# +# Start with "standard". Move to "strict" if you find yourself correcting +# Claude often. Move to "relaxed" once your prompt habits improve. +strictness: standard