diff --git a/BLUEPRINT.md b/BLUEPRINT.md deleted file mode 100644 index a33846f..0000000 --- a/BLUEPRINT.md +++ /dev/null @@ -1,437 +0,0 @@ -# DriftLock Implementation Blueprint - -## Objective -Build a complete API drift detection system with parser, AI agent, CLI+UI, git tracker, Docker sandbox testing, and auto-fix PR generation. - ---- - -## Tech Stack - -| Layer | Technology | Rationale | -|-------|------------|-----------| -| **Runtime** | Bun | Fast installs, native TypeScript, built-in test runner | -| **Monorepo** | Turborepo + Bun workspaces | Fast builds, shared types, clear boundaries | -| **Parser** | tree-sitter + @babel/parser | Multi-language AST support, incremental parsing | -| **Agent** | OpenAI/Anthropic API | Code analysis, fix generation, explanation | -| **CLI** | Bun + Commander.js | Fast iteration, composable commands | -| **UI** | Next.js 14 (App Router) | React Server Components, API routes, Vercel-ready | -| **Database** | PostgreSQL + Drizzle ORM | Type-safe queries, migrations | -| **Queue** | BullMQ + Redis | Background jobs, retry logic, rate limiting | -| **Sandbox** | Docker + dockerode | Isolated test execution, reproducible environments | -| **Git** | simple-git + Octokit | Local git ops + GitHub API integration | -| **Diffing** | json-schema-diff + ast-diff | Structural changes, not just line diffs | - ---- - -## Project Structure - -``` -driftlock/ -├── packages/ -│ ├── core/ # Shared types, utilities, constants -│ │ ├── src/ -│ │ │ ├── types/ # TypeScript interfaces -│ │ │ ├── constants/ # API endpoints, error codes -│ │ │ └── utils/ # Shared helpers -│ │ └── package.json -│ ├── parser/ # AST-based code analysis -│ │ ├── src/ -│ │ │ ├── extractors/ # Language-specific extractors -│ │ │ │ ├── typescript.ts -│ │ │ │ ├── python.ts -│ │ │ │ └── index.ts -│ │ │ ├── analyzers/ # Call site analysis -│ │ │ │ ├── stripe.ts -│ │ │ │ └── base.ts -│ │ │ └── index.ts -│ │ └── package.json -│ ├── agent/ # AI-powered analysis -│ │ ├── src/ -│ │ │ ├── analyzers/ # Change analysis -│ │ │ ├── generators/ # Fix generation -│ │ │ └── index.ts -│ │ └── package.json -│ ├── sandbox/ # Docker test execution -│ │ ├── src/ -│ │ │ ├── runner.ts # Container orchestration -│ │ │ ├── proxy.ts # HTTPS proxy for capture -│ │ │ └── index.ts -│ │ └── package.json -│ └── git/ # Git operations -│ ├── src/ -│ │ ├── tracker.ts # Change detection -│ │ ├── differ.ts # Diff generation -│ │ └── index.ts -│ └── package.json -├── apps/ -│ ├── cli/ # CLI interface -│ │ ├── src/ -│ │ │ ├── commands/ # CLI commands -│ │ │ ├── ui/ # Terminal UI (Ink) -│ │ │ └── index.ts -│ │ └── package.json -│ └── web/ # Web UI -│ ├── src/ -│ │ ├── app/ # Next.js App Router -│ │ ├── components/ # React components -│ │ └── lib/ # API clients, utilities -│ └── package.json -├── docker/ -│ ├── Dockerfile.sandbox # Test execution environment -│ └── docker-compose.yml # Local development -├── turbo.json -└── package.json -``` - ---- - -## Implementation Steps (PR-based) - -### Step 1: Project Scaffolding -**Objective:** Set up monorepo, shared types, and basic infrastructure. - -**Files to Create/Modify:** -- `package.json` (root with workspaces) -- `turbo.json` -- `packages/core/src/types/` (all shared interfaces) -- `packages/core/src/constants/` -- `.gitignore` -- `.env.example` - -**Dependencies:** None - -**Implementation Details:** -1. Initialize Bun monorepo with workspaces -2. Configure Turborepo for build/test/lint pipelines -3. Define core types: `CallSite`, `Snapshot`, `DriftEvent`, `Fix`, `AnalysisResult` -4. Set up shared ESLint + Prettier config -5. Create Docker Compose for local PostgreSQL + Redis - -**Verification:** -- [ ] `bun install` succeeds -- [ ] `bun run build` builds all packages -- [ ] `bun run test` runs (even if no tests yet) -- [ ] Docker Compose starts PostgreSQL + Redis - ---- - -### Step 2: Parser Package -**Objective:** Build AST-based code analysis for extracting API call sites. - -**Files to Create/Modify:** -- `packages/parser/src/extractors/typescript.ts` -- `packages/parser/src/extractors/python.ts` -- `packages/parser/src/analyzers/base.ts` -- `packages/parser/src/analyzers/stripe.ts` -- `packages/parser/src/index.ts` -- `packages/parser/tests/` - -**Dependencies:** Step 1 - -**Implementation Details:** -1. Use tree-sitter for multi-language AST parsing -2. Implement TypeScript extractor: - - Find `stripe.*` member expressions - - Extract method calls (charges.create, customers.retrieve, etc.) - - Infer request/response shapes from arguments and return types -3. Implement Python extractor (for future multi-language support) -4. Create base analyzer interface for vendor-specific logic -5. Build Stripe analyzer with endpoint mapping - -**Verification:** -- [ ] Parses sample TypeScript files with Stripe calls -- [ ] Extracts correct call sites with endpoints -- [ ] Infers request/response shapes -- [ ] Unit tests pass for all extractors - ---- - -### Step 3: Git Tracker Package -**Objective:** Track repository changes and detect drift. - -**Files to Create/Modify:** -- `packages/git/src/tracker.ts` -- `packages/git/src/differ.ts` -- `packages/git/src/index.ts` -- `packages/git/tests/` - -**Dependencies:** Step 1 - -**Implementation Details:** -1. Implement git diff detection using simple-git -2. Track file changes, line additions/deletions -3. Build structural differ for API call sites: - - Detect when call sites are added/removed/modified - - Compare request/response shapes over time - - Classify changes as breaking/non-breaking -4. Store snapshots in database for historical comparison - -**Verification:** -- [ ] Detects file changes in git repository -- [ ] Identifies API call site modifications -- [ ] Generates meaningful diffs (not just line numbers) -- [ ] Persists snapshots for comparison - ---- - -### Step 4: Sandbox Package -**Objective:** Docker-based test execution environment. - -**Files to Create/Modify:** -- `docker/Dockerfile.sandbox` -- `docker/docker-compose.yml` -- `packages/sandbox/src/runner.ts` -- `packages/sandbox/src/proxy.ts` -- `packages/sandbox/src/index.ts` -- `packages/sandbox/tests/` - -**Dependencies:** Step 1 - -**Implementation Details:** -1. Create sandboxed Docker image with: - - Node.js/Python runtime - - Network isolation (only allow specific endpoints) - - Resource limits (CPU, memory, timeout) -2. Implement container orchestration: - - Clone repository - - Install dependencies - - Run test suite - - Capture output -3. Build HTTPS proxy for request/response capture: - - Record all outbound API calls - - Log request/response payloads - - Classify mock vs. real traffic -4. Implement test classification: - - Detect mock libraries (jest.mock, nock, msw) - - Analyze network traffic patterns - - Categorize tests: monitored, tested-but-blind, untested - -**Verification:** -- [ ] Docker container starts and stops cleanly -- [ ] Can clone and run tests in isolated environment -- [ ] Proxy captures API calls correctly -- [ ] Test classification works for common patterns - ---- - -### Step 5: Agent Package -**Objective:** AI-powered analysis and fix generation. - -**Files to Create/Modify:** -- `packages/agent/src/analyzers/` (change analysis) -- `packages/agent/src/generators/` (fix generation) -- `packages/agent/src/index.ts` -- `packages/agent/tests/` - -**Dependencies:** Steps 2, 3, 4 - -**Implementation Details:** -1. Build change analyzer: - - Input: old snapshot, new snapshot, diff - - Output: change summary, impact assessment, confidence score - - Use LLM to understand semantic changes -2. Build fix generator: - - Input: change analysis, call site context - - Output: suggested code fixes with explanations - - Template-based for common patterns - - AI-generated for complex changes -3. Implement confidence scoring: - - High confidence: field renamed, type changed - - Medium confidence: optional became required - - Low confidence: complex logic changes -4. Create prompt engineering for code analysis - -**Verification:** -- [ ] Analyzes API changes correctly -- [ ] Generates meaningful fix suggestions - - [ ] Confidence scores correlate with actual breaking changes - - [ ] Handles edge cases (nested objects, arrays, unions) - ---- - -### Step 6: CLI Application -**Objective:** Command-line interface for local development and testing. - -**Files to Create/Modify:** -- `apps/cli/src/commands/` (analyze, test, fix) -- `apps/cli/src/ui/` (terminal UI with Ink) -- `apps/cli/src/index.ts` -- `apps/cli/package.json` - -**Dependencies:** Steps 2, 3, 4, 5 - -**Implementation Details:** -1. Create CLI commands: - - `driftlock analyze ` - Parse codebase for API calls - - `driftlock test ` - Run tests in sandbox - - `driftlock diff ` - Compare snapshots - - `driftlock fix ` - Generate fix suggestions - - `driftlock watch ` - Continuous monitoring -2. Build terminal UI with Ink: - - Interactive file selection - - Real-time progress indicators - - Color-coded diff output - - Interactive fix preview -3. Implement configuration: - - `.driftlock.yml` for project settings - - Environment variable support - - API key management - -**Verification:** -- [ ] All CLI commands work correctly - - [ ] Terminal UI renders properly - - [ ] Configuration loads from file and env vars - - [ ] Error handling provides helpful messages - ---- - -### Step 7: Web Application -**Objective:** Web UI for team collaboration and visualization. - -**Files to Create/Modify:** -- `apps/web/src/app/` (Next.js pages) -- `apps/web/src/components/` (React components) -- `apps/web/src/lib/` (API clients) -- `apps/web/package.json` - -**Dependencies:** Steps 2, 3, 4, 5 - -**Implementation Details:** -1. Build dashboard pages: - - Overview: recent drift events, coverage stats - - Call Sites: list of detected API usage - - Drift Events: detailed change history - - Fixes: suggested and applied fixes -2. Create interactive components: - - Code viewer with diff highlighting - - Fix preview with before/after - - Coverage map visualization - - Timeline of changes -3. Implement API routes: - - REST API for CLI communication - - WebSocket for real-time updates - - GitHub webhook endpoints -4. Add authentication: - - GitHub OAuth - - Team/organization support - -**Verification:** -- [ ] Dashboard loads and displays data - - [ ] Code viewer renders correctly - - [ ] API routes respond correctly - - [ ] Authentication works - ---- - -### Step 8: PR Generation -**Objective:** Automated PR creation with fix suggestions. - -**Files to Create/Modify:** -- `packages/core/src/pr-generator.ts` -- Integration with GitHub API (Octokit) - -**Dependencies:** Steps 5, 7 - -**Implementation Details:** -1. Build PR template generator: - - What changed (summary) - - Where it affects (file paths, line numbers) - - Suggested fix (diff preview) - - Confidence level - - Coverage note (which tests verify this) -2. Implement branch management: - - Create branches with naming convention - - Handle merge conflicts - - Clean up after merge/close -3. Add PR metadata: - - Labels for drift type - - Assignees based on code ownership - - Milestones for tracking - -**Verification:** -- [ ] Creates well-formatted PRs - - [ ] Branch naming works correctly - - [ ] Cleanup happens after merge - - [ ] PR metadata is accurate - ---- - -### Step 9: Integration & Testing -**Objective:** End-to-end testing and integration. - -**Files to Create/Modify:** -- Integration tests -- E2E tests -- Documentation -- CI/CD pipeline - -**Dependencies:** All previous steps - -**Implementation Details:** -1. Create integration tests: - - Parser + Agent workflow - - Git tracking + Drift detection - - Sandbox + Proxy capture - - CLI + Web coordination -2. Build E2E tests: - - Full drift detection workflow - - PR creation and merge - - Real Stripe API integration -3. Set up CI/CD: - - GitHub Actions for testing - - Automated releases - - Documentation generation - -**Verification:** -- [ ] All integration tests pass - - [ ] E2E tests demonstrate full workflow - - [ ] CI/CD pipeline works - - [ ] Documentation is complete - ---- - -## Adversarial Review Checklist - -- [ ] Are steps in correct order? -- [ ] Are dependencies clear? -- [ ] Are verification criteria specific? -- [ ] Are rollback plans realistic? -- [ ] Is scope appropriate per step? -- [ ] Are there hidden complexity bombs? -- [ ] Does each step deliver testable value? -- [ ] Are security considerations addressed? - ---- - -## Risk Mitigation - -| Risk | Mitigation | -|------|------------| -| Parser complexity | Start with Stripe TypeScript only, expand later | -| Docker performance | Use layer caching, minimal images | -| AI hallucination | Template-based fixes for common patterns | -| False positives | Confidence scoring, manual review required | -| Scope creep | Strict v1 boundaries, defer multi-vendor | - ---- - -## Success Metrics - -- [ ] Parser correctly identifies 90%+ of Stripe call sites - - [ ] Sandbox runs test suites without security issues - - [ ] Agent generates useful fix suggestions - - [ ] CLI commands work reliably - - [ ] Web UI displays information clearly - - [ ] PRs are mergeable without manual editing - ---- - -## Rollback Plan - -Each step can be rolled back independently: -1. **Parser:** Revert to previous version, no data loss -2. **Git:** Revert snapshot storage, keep historical data -3. **Sandbox:** Stop containers, no persistent state -4. **Agent:** Disable AI features, use template-only fixes -5. **CLI/Web:** Revert to previous deployment -6. **PR:** Close unmerged branches, no impact on codebase diff --git a/QUICKSTART.md b/QUICKSTART.md index 4439234..63e494d 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -16,8 +16,11 @@ cd DriftLock # Install dependencies bun install -# Start local services (PostgreSQL + Redis) -docker compose -f docker/docker-compose.yml up -d +# Copy environment config (Bun auto-loads .env from the repo root) +cp .env.example .env + +# Start local services (PostgreSQL) +docker compose up -d postgres # Build all packages bun run build @@ -67,10 +70,30 @@ bun run --filter @driftlock/cli driftlock diff ./repo --base develop With `--base`, the comparison includes committed, staged, and unstaged changes to tracked files. Untracked files are included only in the no-base status report. -### 4. Generate Fixes +### 4. Detect Drift and Generate Fixes + +Run `fix` twice. The first run captures API traffic in a sandbox and stores a +baseline snapshot in `.driftlock/snapshots/`. After the vendor API changes, +re-run to compare captured shapes against the baseline and generate fixes. + +```bash +# First run captures a baseline snapshot +bun run --filter @driftlock/cli driftlock fix ./repo + +# After the vendor API changes, re-run to detect drift +bun run --filter @driftlock/cli driftlock fix ./repo --dry-run + +# Non-interactive test command +bun run --filter @driftlock/cli driftlock fix ./repo --command "bun test" + +# Create a PR with the fix (requires GITHUB_TOKEN) +bun run --filter @driftlock/cli driftlock fix ./repo --repo owner/repo +``` -The `fix` command is not yet available. It exits with an error until the CLI -implements snapshot comparison, drift analysis, and suggestion generation. +Drift triggers on a change in the captured request/response shapes, not on +changes to your own git history. Deterministic fixes (field renames, null +checks, type coercions) are applied statically; the base branch is used only +for the PR's target. ## Development diff --git a/README.md b/README.md index 6105da8..949ddd9 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ API providers announce changes. DriftLock applies them to your codebase. -When Stripe ships a breaking change or a new feature, DriftLock scans your codebase, identifies affected usages, and opens a PR with the fix. +DriftLock scans your codebase for API call sites, captures vendor traffic to build shape snapshots, detects breaking changes between snapshots, and opens a PR with a suggested fix. AI-powered fix generation is on the roadmap. [Website](https://driftlock.dev) · [Discord](https://discord.gg/driftlock) · [Issues](https://github.com/nerdev-co/DriftLock/issues) @@ -34,13 +34,72 @@ flowchart LR --- -## Why DriftLock +## The problem statement + +The original pitch that started DriftLock, verbatim: + +> Over the past year, I've worked with over 50 API vendors, mostly early-stage +> startups. One pattern is consistent: API communication is broken. +> +> Breaking changes ship with little warning. Useful features quietly launch and +> go unnoticed. Changelogs don't get read. Heck, when I worked at AWS, over 30% +> of our service downtime was due to external api/package changes going +> unnoticed. This friction made sense before agentic coding tools existed. +> However, now it doesn't. +> +> Agentic coding tools like Claude Code, Devin, Greptile, etc prove that +> developers and enterprises are willing to give codebase access to external +> tools, provided they're valuable. Two years ago, this was unthinkable. Now +> it's standard practice. +> +> The infrastructure for automated code changes exists. What's missing is the +> application layer connecting API providers to their customers' codebases. API +> providers shouldn't just announce changes; they should apply them. +> +> When Stripe ships a breaking change or a new feature, an agent should scan +> customer codebases, identify affected usages, and open a PR with the fix. +> +> This could work as per-provider agents. "Install Stripe's update agent", or +> as a neutral third-party service tracking changes across vendors, like +> Dependabot but for APIs. If you're working on this, consider applying to YC. + +That last line is the entire product in four words: **"Dependabot, but for +APIs"**. The sentence before it is the litmus test we use against every +feature in this repo: + +> *An agent scans customer codebases, identifies affected usages, and opens a +> PR with the fix.* + +If a proposed feature does not move DriftLock toward that, it's plumbing or +scope creep. This section is the guard against drift. -API communication is broken. Breaking changes ship with little warning. Useful features quietly launch and go unnoticed. Changelogs don't get read. +--- + +## What DriftLock is + +DriftLock is the application layer connecting API providers to their customers' +codebases. It's a neutral third-party service tracking changes across vendors. +The codebase access is a solved problem (agentic tools proved it); the +**application layer** is what's missing. + +The cost of a vendor change always lands on the consumer. DriftLock moves it +back to automation: it scans your codebase for API call sites, watches for +vendor changes, detects how they affect your usages, and opens a PR with the +fix. AI-powered fix generation is on the roadmap. + +--- -The cost always lands on you (the consumer), not the vendor who made the change. +## Personal story -DriftLock makes APIs self-maintaining. When a vendor changes something, your codebase updates automatically. You review the PR and merge. No manual scanning. No migration guides. No 2am pages. +I built DriftLock because I got bitten by an API break myself. + +I had a Next.js app running on Prisma 6. Then Prisma 7 shipped, and the app broke. I didn't catch it until right before my interviews, if I hadn't noticed in time, it would have blown up in production at the worst possible moment. + +That's when it clicked: dependency upgrades don't just bump a version number. They change the actual code you write. Changelogs are easy to miss. Migration guides are easy to skip. Semver doesn't save you when the API surface changes. + +What I needed wasn't another tool that tells me a dependency is out of date. I needed something that would automatically update the affected code in my codebase — something that makes my APIs self-maintaining. + +That's DriftLock. --- @@ -56,16 +115,16 @@ flowchart LR F --> G[Review & Merge] ``` -| Step | What happens | -| ------------ | -------------------------------------------------------- | -| **Discover** | Static analysis finds every API call in your codebase | -| **Classify** | Identifies which tests hit real sandbox vs. mocked | -| **Probe** | Runs your tests, captures actual request/response shapes | -| **Diff** | Compares current shapes against target version | -| **Fix** | Opens PRs with the diffs and suggested fixes | -| **Report** | Shows which call sites are monitored, blind, or untested | +| Step | What happens | +| ------------ | -------------------------------------------------------------- | +| **Scan** | Static analysis finds every API call in your codebase | +| **Classify** | Identifies which tests hit real sandbox vs. mocked | +| **Probe** | Runs your tests, captures actual request/response shapes | +| **Diff** | Compares captured shapes against the baseline snapshot | +| **Fix** | Generates fix suggestions; PR creation available with `--repo` | +| **Report** | Shows which call sites are monitored, blind, or untested | -The goal: when Stripe ships a change, your codebase updates automatically. You just review and merge. +AI-powered fix generation is on the roadmap. The current implementation produces fix suggestions and supports PR creation. --- @@ -81,8 +140,11 @@ driftlock analyze ./src # Run in sandbox driftlock test ./repo -# Generate fixes -driftlock fix ./repo +# Detect drift +driftlock fix ./repo --dry-run + +# Create PR with suggested fix +driftlock fix ./repo --repo owner/repo ``` [Full documentation →](./docs/architecture.md) @@ -91,13 +153,13 @@ driftlock fix ./repo ## What you're used to vs. what DriftLock does -| Today | With DriftLock | -| -------------------------------------- | -------------------------------------- | -| Avoid upgrades because they're tedious | Automated codebase scanning | -| Manually find affected call sites | All affected calls found automatically | -| Copy-paste migration guide changes | Fix diffs generated and ready to merge | -| Weeks to upgrade, so you put it off | Minutes to review a PR | -| Stuck on old versions | Stay current with minimal effort | +| Today | With DriftLock | +| -------------------------------------- | ------------------------------------------------ | +| Avoid upgrades because they're tedious | Automated codebase scanning | +| Manually find affected call sites | All affected calls found automatically | +| Copy-paste migration guide changes | Fix suggestions generated, PR creation available | +| Weeks to upgrade, so you put it off | Minutes to review a PR | +| Stuck on old versions | Stay current with minimal effort | --- @@ -135,13 +197,15 @@ Stripe has mature test mode, huge installed base, and plenty of teams stuck on o Twilio, Shopify, and others are on the roadmap. +AI-powered fix generation is on the roadmap. The current implementation captures traffic shapes, detects drift between snapshots, and applies deterministic fixes; full automated PR generation with AI-generated patches is planned. + --- ## Security If you discover a security vulnerability, please report it responsibly. -**Email:** security@driftlock.dev +**Email:** nalin@nerdev.in Do NOT open a public GitHub issue for security vulnerabilities. diff --git a/apps/cli/drift.ts b/apps/cli/drift.ts new file mode 100644 index 0000000..808d268 --- /dev/null +++ b/apps/cli/drift.ts @@ -0,0 +1,235 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { join } from "path"; +import type { CallSite, DiffSummary, DriftEvent, Fix } from "@driftlock/core"; +import type { TrafficCapture } from "@driftlock/sandbox"; +import { + applyFixWork, + diffShapes, + fixWorksForDiff, + inferShape, + type FixWork, + type Shape, + type ShapeDiffResult, +} from "@driftlock/diff"; + +export interface CapturedShapes { + request: Shape; + response: Shape; +} + +export class SnapshotStore { + private readonly directory: string; + + constructor(repoPath: string) { + this.directory = join(repoPath, ".driftlock", "snapshots"); + } + + load(callSiteId: string): CapturedShapes | null { + const file = join(this.directory, `${callSiteId}.json`); + if (!existsSync(file)) { + return null; + } + try { + return JSON.parse(readFileSync(file, "utf8")) as CapturedShapes; + } catch { + return null; + } + } + + save(callSiteId: string, shapes: CapturedShapes): void { + if (!existsSync(this.directory)) { + mkdirSync(this.directory, { recursive: true }); + } + writeFileSync( + join(this.directory, `${callSiteId}.json`), + JSON.stringify(shapes, null, 2), + ); + } +} + +export function extractShapesFromCaptures( + captures: TrafficCapture[], + callSites: CallSite[], +): Map { + const byId = new Map< + string, + { request?: Shape; response?: Shape } + >(); + + for (const callSite of callSites) { + for (const capture of captures) { + if (!matchesEndpoint(capture, callSite)) { + continue; + } + const entry = byId.get(callSite.id) ?? {}; + if (!entry.request) { + entry.request = shapeOf(capture.body) ?? undefined; + } + if (!entry.response) { + entry.response = + shapeOf(capture.response?.body) ?? undefined; + } + byId.set(callSite.id, entry); + } + } + + const result = new Map(); + for (const [id, entry] of byId) { + if (entry.request && entry.response) { + result.set(id, { + request: entry.request, + response: entry.response, + }); + } + } + return result; +} + +export interface DriftResult { + callSite: CallSite; + previous: CapturedShapes; + current: CapturedShapes; + requestDiff: ShapeDiffResult; + responseDiff: ShapeDiffResult; + works: FixWork[]; +} + +export function buildDriftResult( + callSite: CallSite, + previous: CapturedShapes, + current: CapturedShapes, +): DriftResult { + const requestDiff = diffShapes(previous.request, current.request, { + direction: "request", + }); + const responseDiff = diffShapes(previous.response, current.response); + const works = [ + ...fixWorksForDiff(requestDiff), + ...fixWorksForDiff(responseDiff), + ]; + return { + callSite, + previous, + current, + requestDiff, + responseDiff, + works, + }; +} + +export function driftSummary(drift: DriftResult): DiffSummary { + return { + addedFields: [ + ...drift.requestDiff.addedFields, + ...drift.responseDiff.addedFields, + ], + removedFields: [ + ...drift.requestDiff.removedFields, + ...drift.responseDiff.removedFields, + ], + typeChanges: [ + ...drift.requestDiff.typeChanges, + ...drift.responseDiff.typeChanges, + ], + optionalityChanges: [ + ...drift.requestDiff.optionalityChanges, + ...drift.responseDiff.optionalityChanges, + ], + breakingChanges: [ + ...drift.requestDiff.breakingChanges, + ...drift.responseDiff.breakingChanges, + ], + nonBreakingChanges: [ + ...drift.requestDiff.nonBreakingChanges, + ...drift.responseDiff.nonBreakingChanges, + ], + }; +} + +export function driftConfidence(drift: DriftResult): "high" | "medium" | "low" { + const levels = ["high", "medium", "low"] as const; + const a = levels.indexOf(drift.requestDiff.confidence); + const b = levels.indexOf(drift.responseDiff.confidence); + return levels[Math.max(a, b)]; +} + +export function applyDriftFix( + drift: DriftResult, + source: string, +): { fix: Fix; changes: string } | null { + if (drift.works.length === 0) { + return null; + } + const changes = drift.works.reduce( + (acc, work) => applyFixWork(work, acc) ?? acc, + source, + ); + if (changes === source) { + return null; + } + const primary = drift.works[0]; + const fix: Fix = { + id: `fix-${drift.callSite.id}`, + driftEventId: `drift-${drift.callSite.id}`, + type: primary.kind, + description: primary.description, + diff: + primary.from && primary.to + ? `- ${primary.from}\n+ ${primary.to}` + : primary.template, + confidence: driftConfidence(drift), + files: [{ path: drift.callSite.filePath, changes }], + generatedAt: new Date(), + }; + return { fix, changes }; +} + +export function buildDriftEvent(drift: DriftResult): DriftEvent { + return { + id: `drift-${drift.callSite.id}`, + callSiteId: drift.callSite.id, + detectedAt: new Date(), + oldSnapshotId: `snap-${drift.callSite.id}-previous`, + newSnapshotId: `snap-${drift.callSite.id}-current`, + diffSummary: driftSummary(drift), + suggestedFix: null, + confidence: driftConfidence(drift), + prNumber: null, + status: "detected", + }; +} + +function matchesEndpoint( + capture: TrafficCapture, + callSite: CallSite, +): boolean { + if (capture.method !== callSite.httpMethod) { + return false; + } + let pathname: string; + try { + pathname = new URL(capture.url).pathname; + } catch { + return false; + } + const expected = callSite.endpoint.split("/"); + const actual = pathname.split("/"); + if (expected.length !== actual.length) { + return false; + } + return expected.every( + (segment, index) => + segment.startsWith(":") || segment === actual[index], + ); +} + +function shapeOf(value: unknown): Shape | null { + if (typeof value !== "object" || value === null) { + return null; + } + const node = inferShape(value); + if (node.kind !== "object" || !node.properties) { + return null; + } + return node.properties; +} \ No newline at end of file diff --git a/apps/cli/index.ts b/apps/cli/index.ts index 3a05106..75aae1f 100644 --- a/apps/cli/index.ts +++ b/apps/cli/index.ts @@ -4,20 +4,51 @@ import ora from "ora"; import inquirer from "inquirer"; import { TypeScriptExtractor } from "@driftlock/parser"; import { SandboxRunner } from "@driftlock/sandbox"; -import { GitTracker } from "@driftlock/git"; +import { GitTracker, PRGenerator } from "@driftlock/git"; +import type { CallSite, Fix } from "@driftlock/core"; +import { + SnapshotStore, + buildDriftEvent, + buildDriftResult, + extractShapesFromCaptures, + applyDriftFix, + type DriftResult, +} from "./drift"; const program = new Command(); program .name("driftlock") - .description("API drift detection and fix generation") - .version("0.1.0"); + .description("Self-maintaining APIs. Detect drift, generate fix PRs") + .version("0.1.0") + .addHelpText( + "after", + ` +Examples: + $ driftlock analyze ./src + $ driftlock test ./repo --command "npm test" + $ driftlock diff ./repo --base main + $ driftlock fix ./repo --repo owner/repo --dry-run + $ driftlock init +`, + ); program .command("analyze") - .description("Analyze codebase for API call sites") - .argument("", "Path to analyze") + .description("Scan codebase for API call sites (Stripe, Twilio, etc.)") + .argument("", "Directory to scan for TypeScript/JavaScript files") .option("-o, --output ", "Output format (json, table)", "table") + .addHelpText( + "after", + ` +Scans your codebase using AST analysis to find all API call sites. +Currently supports Stripe SDK calls (stripe.charges.create, etc.). + +Output formats: + json Machine-readable JSON with call sites and errors + table Human-readable table with file locations and endpoints +`, + ) .action(async (path: string, options: { output: string }) => { const spinner = ora("Analyzing codebase...").start(); @@ -89,10 +120,22 @@ program program .command("test") - .description("Run tests in sandbox environment") - .argument("", "Path to test") + .description("Run tests in isolated Docker sandbox with traffic capture") + .argument("", "Repository path to test") .option("-c, --command ", "Test command to run", "npm test") .option("-t, --timeout ", "Timeout in milliseconds", "300000") + .addHelpText( + "after", + ` +Runs your test suite in an isolated Docker container with resource limits. +Captures HTTP traffic to detect which tests hit real APIs vs mocks. + +The sandbox ensures: + - Network isolation (only allowed endpoints) + - Resource limits (CPU, memory) + - Reproducible environments +`, + ) .action( async (path: string, options: { command: string; timeout: string }) => { const spinner = ora("Running tests in sandbox...").start(); @@ -138,9 +181,18 @@ program program .command("diff") - .description("Compare API snapshots") + .description("Compare API snapshots between branches or over time") .argument("", "Repository path") - .option("-b, --base ", "Base branch to compare") + .option("-b, --base ", "Base branch to compare against") + .addHelpText( + "after", + ` +Detects file changes in your repository and identifies which +API call sites are affected by those changes. + +Use this to understand the impact of a branch before merging. +`, + ) .action(async (path: string, options: { base?: string }) => { const spinner = ora("Comparing snapshots...").start(); @@ -193,18 +245,291 @@ program program .command("fix") - .description("Generate fix suggestions (not yet available)") + .description("Detect API drift and generate fix PRs") .argument("", "Repository path") - .action(() => { - console.error( - "Fix generation is not yet available: snapshot comparison and drift analysis are not implemented in the CLI.", - ); - process.exitCode = 1; - }); + .option("-b, --base ", "Base branch to compare", "main") + .option("-r, --repo ", "GitHub repo (owner/repo) for PR creation") + .option("-c, --command ", "Test command to run for capture", "npm test") + .option("--dry-run", "Show affected call sites without creating PRs") + .addHelpText( + "after", + ` +The core DriftLock loop: + 1. Scans for API call sites in your codebase + 2. Runs your test suite in a sandbox through a traffic-capture proxy + 3. First run establishes a baseline snapshot (.driftlock/snapshots) + 4. Later runs compare captured shapes against the baseline to detect drift + 5. Generates deterministic fixes (renames, null checks, coercions) and + creates a PR with the fix (if --repo is provided) + +Environment variables: + GITHUB_TOKEN Required for PR creation (not needed for --dry-run) + +Examples: + $ driftlock fix ./repo --dry-run + $ driftlock fix ./repo --repo owner/repo + $ driftlock fix ./repo --command "bun test" +`, + ) + .action( + async ( + repoPath: string, + options: { + base?: string; + repo?: string; + dryRun?: boolean; + command?: string; + }, + ) => { + const spinner = ora("Starting drift detection...").start(); + + try { + // Step 1: Scan for call sites + spinner.text = "Scanning for API call sites..."; + const extractor = new TypeScriptExtractor(); + const fs = await import("fs"); + const pathModule = await import("path"); + + const files = fs + .readdirSync(repoPath, { recursive: true }) + .filter( + (file): file is string => + typeof file === "string" && + (file.endsWith(".ts") || file.endsWith(".js")), + ); + + const allCallSites = []; + for (const file of files) { + const filePath = pathModule.join(repoPath, file); + const content = fs.readFileSync(filePath, "utf-8"); + const result = await extractor.extractFromFile( + filePath, + content, + ); + allCallSites.push(...result.callSites); + } + + spinner.text = `Found ${allCallSites.length} API call sites`; + + if (allCallSites.length === 0) { + spinner.warn("No API call sites found"); + return; + } + + // Step 2: Capture traffic through the sandbox proxy + spinner.text = "Running sandbox test with traffic capture..."; + const runner = new SandboxRunner(); + const sandbox = await runner.runTestSuite(repoPath, { + image: "node:20-slim", + command: ["sh", "-c", options.command ?? "npm test"], + env: {}, + timeout: 300000, + memoryLimit: "512m", + cpuLimit: 1.0, + networkEnabled: true, + allowedEndpoints: [], + }); + + if (sandbox.exitCode !== 0 && sandbox.stderr) { + spinner.warn( + `Sandbox exited ${sandbox.exitCode}: ${sandbox.stderr.slice(0, 200)}`, + ); + } + + const store = new SnapshotStore(repoPath); + const shapes = extractShapesFromCaptures( + sandbox.trafficCaptured, + allCallSites, + ); + spinner.text = `Captured traffic for ${shapes.size}/${allCallSites.length} call sites`; + + // Step 3: Compare against the stored baseline + const baselines: CallSite[] = []; + const drifts: DriftResult[] = []; + for (const callSite of allCallSites) { + const current = shapes.get(callSite.id); + if (!current) { + continue; + } + const previous = store.load(callSite.id); + if (!previous) { + store.save(callSite.id, current); + baselines.push(callSite); + continue; + } + const drift = buildDriftResult( + callSite, + previous, + current, + ); + if (drift.works.length > 0) { + drifts.push(drift); + } + } + + if (baselines.length > 0) { + console.log(chalk.bold("\nBaseline snapshots captured:")); + for (const cs of baselines) { + console.log( + ` ${chalk.cyan(cs.filePath)}:${chalk.yellow(cs.line)} (${chalk.green(cs.method)})`, + ); + } + console.log( + chalk.dim( + "\nRe-run after the vendor API changes to detect drift.", + ), + ); + } + + if (drifts.length === 0) { + if (baselines.length === 0) { + spinner.succeed("No drift detected"); + } else { + spinner.succeed( + "Baseline captured, no comparison yet", + ); + } + return; + } + + spinner.succeed( + `Detected drift at ${drifts.length} call site(s)`, + ); + + // Step 4: Apply deterministic fixes + const fixes: Array<{ + callSite: CallSite; + drift: DriftResult; + fix: Fix; + }> = []; + for (const drift of drifts) { + const file = pathModule.join( + repoPath, + drift.callSite.filePath, + ); + let content: string; + try { + content = fs.readFileSync(file, "utf8"); + } catch { + content = ""; + } + const applied = applyDriftFix(drift, content); + if (!applied) { + console.log( + chalk.yellow( + ` ${drift.callSite.filePath}:${drift.callSite.line}: no static fix applicable`, + ), + ); + continue; + } + fixes.push({ + callSite: drift.callSite, + drift, + fix: applied.fix, + }); + console.log( + chalk.bold( + `\n${chalk.cyan(drift.callSite.filePath)}:${chalk.yellow(drift.callSite.line)}`, + ), + ); + console.log( + ` ${chalk.green(drift.callSite.method)} → ${chalk.blue(drift.callSite.endpoint)}`, + ); + console.log( + ` ${chalk.yellow("Fix:")} ${applied.fix.description}`, + ); + console.log( + ` ${chalk.dim(applied.fix.diff)}`, + ); + } + + if (fixes.length === 0) { + spinner.succeed("No statically applicable fixes"); + return; + } + + // Step 5: Create PR (if not dry run and repo is provided) + if (options.dryRun) { + console.log( + chalk.yellow( + "\nDry run, skipping PR creation. Remove --dry-run to create PRs.", + ), + ); + return; + } + + if (!options.repo) { + console.log( + chalk.yellow( + "\nNo --repo specified. Skipping PR creation. Use --repo owner/repo to create PRs.", + ), + ); + return; + } + + const [owner, repo] = options.repo.split("/"); + if (!owner || !repo) { + console.error( + chalk.red("Invalid --repo format. Use owner/repo."), + ); + process.exit(1); + } + + const githubToken = process.env.GITHUB_TOKEN; + if (!githubToken) { + console.error( + chalk.red( + "GITHUB_TOKEN environment variable is required for PR creation.", + ), + ); + process.exit(1); + } + + const prSpinner = ora("Creating PR...").start(); + const prGenerator = new PRGenerator(githubToken); + + for (const { callSite, drift, fix } of fixes) { + const driftEvent = buildDriftEvent(drift); + driftEvent.suggestedFix = fix; + driftEvent.status = "fix_generated"; + + const pr = await prGenerator.createFixPR( + owner, + repo, + { + driftEvent, + callSite, + fix, + files: fix.files, + }, + options.base, + ); + + prSpinner.succeed(`PR created: ${pr.url}`); + } + } catch (error) { + spinner.fail("Fix generation failed"); + console.error(error); + process.exit(1); + } + }, + ); program .command("init") - .description("Initialize DriftLock configuration") + .description("Initialize DriftLock configuration in current directory") + .addHelpText( + "after", + ` +Creates a .driftlock.yml configuration file with: + - Test command (default: npm test) + - HTTPS proxy settings for traffic capture + - Sandbox configuration (Docker image, resource limits) + +Environment variables: + OPENAI_API_KEY Required for AI-powered fix generation +`, + ) .action(async () => { const spinner = ora("Initializing DriftLock...").start(); diff --git a/apps/cli/package.json b/apps/cli/package.json index 4c835d9..72793c1 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -15,6 +15,8 @@ }, "dependencies": { "@driftlock/agent": "workspace:*", + "@driftlock/core": "workspace:*", + "@driftlock/diff": "workspace:*", "@driftlock/git": "workspace:*", "@driftlock/parser": "workspace:*", "@driftlock/sandbox": "workspace:*", diff --git a/apps/webhook/index.ts b/apps/webhook/index.ts new file mode 100644 index 0000000..e6a6a91 --- /dev/null +++ b/apps/webhook/index.ts @@ -0,0 +1,71 @@ +import { webhookHandler } from "./webhooks"; +import { getDb } from "@driftlock/db"; + +const port = parseInt(process.env.PORT || "3001", 10); + +export function json(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data, null, 2), { + status, + headers: { "content-type": "application/json" }, + }); +} + +// Verify database connection on startup +try { + getDb(); + console.log("Database connection established"); +} catch (error) { + console.warn( + "Database connection failed. Webhooks will log but not persist:", + error, + ); +} + +console.log(`DriftLock webhook server starting on port ${port}`); + +Bun.serve({ + port, + async fetch(req) { + const url = new URL(req.url); + + if (url.pathname === "/webhooks/github") { + return await webhookHandler(req); + } + + if (url.pathname === "/health") { + try { + const db = getDb(); + await db.execute("SELECT 1"); + return json({ + status: "ok", + database: "connected", + timestamp: new Date().toISOString(), + }); + } catch (error) { + return json( + { + status: "degraded", + database: "disconnected", + timestamp: new Date().toISOString(), + }, + 503, + ); + } + } + + if (url.pathname === "/") { + return json({ + name: "DriftLock", + version: "0.1.0", + description: + "Self-maintaining APIs. GitHub App webhook handler", + endpoints: { + webhooks: "/webhooks/github", + health: "/health", + }, + }); + } + + return json({ error: "Not found" }, 404); + }, +}); diff --git a/apps/webhook/manifest.json b/apps/webhook/manifest.json new file mode 100644 index 0000000..938fccf --- /dev/null +++ b/apps/webhook/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "DriftLock", + "url": "https://driftlock.dev", + "hook_attributes": { + "url": "TODO: Set your webhook URL (e.g., https://your-domain.com/webhooks/github)" + }, + "redirect_url": "TODO: Set your redirect URL (e.g., https://your-domain.com/auth/callback)", + "public": true, + "default_permissions": { + "contents": "write", + "issues": "write", + "metadata": "read", + "pull_requests": "write" + }, + "default_events": [ + "installation", + "installation_repositories", + "push", + "pull_request" + ] +} diff --git a/apps/webhook/package.json b/apps/webhook/package.json new file mode 100644 index 0000000..d8a5109 --- /dev/null +++ b/apps/webhook/package.json @@ -0,0 +1,26 @@ +{ + "name": "@driftlock/webhook", + "version": "0.1.0", + "private": true, + "main": "./index.ts", + "type": "module", + "scripts": { + "dev": "bun --env-file=../../.env run --watch index.ts", + "start": "bun --env-file=../../.env run index.ts", + "typecheck": "tsc --noEmit", + "lint": "eslint . --ext .ts" + }, + "dependencies": { + "@driftlock/agent": "workspace:*", + "@driftlock/core": "workspace:*", + "@driftlock/db": "workspace:*", + "@driftlock/git": "workspace:*", + "@driftlock/parser": "workspace:*", + "drizzle-orm": "^0.45.2", + "octokit": "^4.1.0" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.3.0" + } +} diff --git a/apps/webhook/tsconfig.json b/apps/webhook/tsconfig.json new file mode 100644 index 0000000..2d5f23d --- /dev/null +++ b/apps/webhook/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "." + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/webhook/webhooks.ts b/apps/webhook/webhooks.ts new file mode 100644 index 0000000..bd26a8f --- /dev/null +++ b/apps/webhook/webhooks.ts @@ -0,0 +1,168 @@ +import { createHmac, timingSafeEqual } from "crypto"; +import { getDb, installations, repositories } from "@driftlock/db"; +import { eq } from "drizzle-orm"; + +const WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET || ""; + +function json(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data, null, 2), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function verifySignature(payload: string, signature: string): boolean { + if (!WEBHOOK_SECRET) { + console.warn("No GITHUB_WEBHOOK_SECRET set. Skipping signature verification"); + return true; + } + + const expected = "sha256=" + + createHmac("sha256", WEBHOOK_SECRET).update(payload).digest("hex"); + + return timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); +} + +export async function webhookHandler(req: Request): Promise { + if (req.method !== "POST") { + return json({ error: "Method not allowed" }, 405); + } + + const signature = req.headers.get("x-hub-signature-256") || ""; + const eventType = req.headers.get("x-github-event") || ""; + const deliveryId = req.headers.get("x-github-delivery") || ""; + + const body = await req.text(); + + if (!verifySignature(body, signature)) { + console.error(`Invalid signature for delivery ${deliveryId}`); + return json({ error: "Invalid signature" }, 401); + } + + console.log(`Received ${eventType} event (delivery: ${deliveryId})`); + + try { + const payload = JSON.parse(body); + + switch (eventType) { + case "installation": + await handleInstallation(payload); + break; + case "installation_repositories": + await handleInstallationRepositories(payload); + break; + case "push": + await handlePush(payload); + break; + case "pull_request": + await handlePullRequest(payload); + break; + default: + console.log(`Unhandled event type: ${eventType}`); + } + + return json({ received: true }); + } catch (error) { + console.error(`Error processing ${eventType} event:`, error); + return json({ error: "Internal server error" }, 500); + } +} + +async function handleInstallation(payload: any) { + const { action, installation, repositories: repos } = payload; + const db = getDb(); + + console.log(`Installation ${action}: ${installation.account.login}`); + + switch (action) { + case "created": + // Save installation + await db.insert(installations).values({ + installationId: installation.id, + accountLogin: installation.account.login, + accountType: installation.account.type, + appId: installation.app_id, + targetSelection: installation.target_selection, + permissions: installation.permissions, + events: installation.events, + }); + + // Save repositories + if (repos?.length) { + await db.insert(repositories).values( + repos.map((repo: any) => ({ + owner: repo.owner.login, + name: repo.name, + fullName: repo.full_name, + installationId: installation.id, + defaultBranch: repo.default_branch || "main", + })) + ); + } + + console.log(` Saved installation with ${repos?.length || 0} repositories`); + break; + + case "deleted": + // Remove installation and cascade delete repositories + await db + .delete(installations) + .where(eq(installations.installationId, installation.id)); + console.log(` Installation deleted`); + break; + } +} + +async function handleInstallationRepositories(payload: any) { + const { action, installation, repositories_added, repositories_removed } = payload; + const db = getDb(); + + console.log(`Installation repositories ${action}: ${installation.account.login}`); + + switch (action) { + case "added": + if (repositories_added?.length) { + await db.insert(repositories).values( + repositories_added.map((repo: any) => ({ + owner: repo.owner.login, + name: repo.name, + fullName: repo.full_name, + installationId: installation.id, + defaultBranch: repo.default_branch || "main", + })) + ); + console.log(` Added: ${repositories_added.map((r: any) => r.full_name).join(", ")}`); + } + break; + + case "removed": + if (repositories_removed?.length) { + for (const repo of repositories_removed) { + await db + .delete(repositories) + .where(eq(repositories.fullName, repo.full_name)); + } + console.log(` Removed: ${repositories_removed.map((r: any) => r.full_name).join(", ")}`); + } + break; + } +} + +async function handlePush(payload: any) { + const { repository, ref, commits } = payload; + + console.log(`Push to ${repository.full_name}: ${ref}`); + console.log(` ${commits?.length || 0} commits`); + + // TODO: Check if any commits affect API call sites + // TODO: Run drift detection if relevant files changed +} + +async function handlePullRequest(payload: any) { + const { action, pull_request, repository } = payload; + + console.log(`PR ${action}: ${pull_request.title} in ${repository.full_name}`); + + // TODO: If PR is merged, check for API changes + // TODO: If PR is opened by DriftLock, track status +} diff --git a/assets/logo-icon-v2.svg b/assets/logo-icon-v2.svg new file mode 100644 index 0000000..c966f28 --- /dev/null +++ b/assets/logo-icon-v2.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/assets/logo-light-v2.svg b/assets/logo-light-v2.svg new file mode 100644 index 0000000..9721a5c --- /dev/null +++ b/assets/logo-light-v2.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/assets/logo-v2.svg b/assets/logo-v2.svg new file mode 100644 index 0000000..1db36a9 --- /dev/null +++ b/assets/logo-v2.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/bun.lock b/bun.lock index 4654b53..67fa698 100644 --- a/bun.lock +++ b/bun.lock @@ -6,8 +6,8 @@ "name": "driftlock", "devDependencies": { "@types/inquirer": "^9.0.10", - "@typescript-eslint/eslint-plugin": "^6.19.0", - "@typescript-eslint/parser": "^6.19.0", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", "c8": "^9.1.0", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", @@ -24,6 +24,8 @@ }, "dependencies": { "@driftlock/agent": "workspace:*", + "@driftlock/core": "workspace:*", + "@driftlock/diff": "workspace:*", "@driftlock/git": "workspace:*", "@driftlock/parser": "workspace:*", "@driftlock/sandbox": "workspace:*", @@ -37,6 +39,23 @@ "typescript": "^5.3.0", }, }, + "apps/webhook": { + "name": "@driftlock/webhook", + "version": "0.1.0", + "dependencies": { + "@driftlock/agent": "workspace:*", + "@driftlock/core": "workspace:*", + "@driftlock/db": "workspace:*", + "@driftlock/git": "workspace:*", + "@driftlock/parser": "workspace:*", + "drizzle-orm": "^0.45.2", + "octokit": "^4.1.0", + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.3.0", + }, + }, "packages/agent": { "name": "@driftlock/agent", "version": "0.1.0", @@ -62,12 +81,23 @@ "version": "0.1.0", "dependencies": { "@driftlock/core": "workspace:*", - "drizzle-orm": "^0.36.0", + "drizzle-orm": "^0.45.2", "postgres": "^3.4.0", }, "devDependencies": { "@types/node": "^20.11.0", - "drizzle-kit": "^0.28.0", + "drizzle-kit": "^0.31.10", + "typescript": "^5.3.0", + }, + }, + "packages/diff": { + "name": "@driftlock/diff", + "version": "0.1.0", + "dependencies": { + "@driftlock/core": "workspace:*", + }, + "devDependencies": { + "@types/node": "^20.11.0", "typescript": "^5.3.0", }, }, @@ -76,6 +106,7 @@ "version": "0.1.0", "dependencies": { "@driftlock/core": "workspace:*", + "octokit": "^4.1.0", "simple-git": "^3.22.0", }, "devDependencies": { @@ -118,6 +149,7 @@ "dependencies": { "@driftlock/agent": "workspace:*", "@driftlock/core": "workspace:*", + "@driftlock/diff": "workspace:*", "@driftlock/git": "workspace:*", "@driftlock/parser": "workspace:*", "@driftlock/sandbox": "workspace:*", @@ -141,6 +173,8 @@ "@driftlock/db": ["@driftlock/db@workspace:packages/db"], + "@driftlock/diff": ["@driftlock/diff@workspace:packages/diff"], + "@driftlock/git": ["@driftlock/git@workspace:packages/git"], "@driftlock/parser": ["@driftlock/parser@workspace:packages/parser"], @@ -149,57 +183,65 @@ "@driftlock/tests": ["@driftlock/tests@workspace:packages/tests"], + "@driftlock/webhook": ["@driftlock/webhook@workspace:apps/webhook"], + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="], "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.19.12", "", { "os": "android", "cpu": "arm64" }, "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.19.12", "", { "os": "android", "cpu": "x64" }, "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.19.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.19.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.19.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.19.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.19.12", "", { "os": "linux", "cpu": "arm" }, "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.19.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.19.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.19.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.19.12", "", { "os": "linux", "cpu": "none" }, "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.19.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.19.12", "", { "os": "linux", "cpu": "x64" }, "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.19.12", "", { "os": "none", "cpu": "x64" }, "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.19.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.19.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.19.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.19.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.19.12", "", { "os": "win32", "cpu": "x64" }, "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="], @@ -243,6 +285,56 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + "@octokit/app": ["@octokit/app@15.1.6", "", { "dependencies": { "@octokit/auth-app": "^7.2.1", "@octokit/auth-unauthenticated": "^6.1.3", "@octokit/core": "^6.1.5", "@octokit/oauth-app": "^7.1.6", "@octokit/plugin-paginate-rest": "^12.0.0", "@octokit/types": "^14.0.0", "@octokit/webhooks": "^13.6.1" } }, "sha512-WELCamoCJo9SN0lf3SWZccf68CF0sBNPQuLYmZ/n87p5qvBJDe9aBtr5dHkh7T9nxWZ608pizwsUbypSzZAiUw=="], + + "@octokit/auth-app": ["@octokit/auth-app@7.2.2", "", { "dependencies": { "@octokit/auth-oauth-app": "^8.1.4", "@octokit/auth-oauth-user": "^5.1.4", "@octokit/request": "^9.2.3", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "toad-cache": "^3.7.0", "universal-github-app-jwt": "^2.2.0", "universal-user-agent": "^7.0.0" } }, "sha512-p6hJtEyQDCJEPN9ijjhEC/kpFHMHN4Gca9r+8S0S8EJi7NaWftaEmexjxxpT1DFBeJpN4u/5RE22ArnyypupJw=="], + + "@octokit/auth-oauth-app": ["@octokit/auth-oauth-app@8.1.4", "", { "dependencies": { "@octokit/auth-oauth-device": "^7.1.5", "@octokit/auth-oauth-user": "^5.1.4", "@octokit/request": "^9.2.3", "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-71iBa5SflSXcclk/OL3lJzdt4iFs56OJdpBGEBl1wULp7C58uiswZLV6TdRaiAzHP1LT8ezpbHlKuxADb+4NkQ=="], + + "@octokit/auth-oauth-device": ["@octokit/auth-oauth-device@7.1.5", "", { "dependencies": { "@octokit/oauth-methods": "^5.1.5", "@octokit/request": "^9.2.3", "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-lR00+k7+N6xeECj0JuXeULQ2TSBB/zjTAmNF2+vyGPDEFx1dgk1hTDmL13MjbSmzusuAmuJD8Pu39rjp9jH6yw=="], + + "@octokit/auth-oauth-user": ["@octokit/auth-oauth-user@5.1.6", "", { "dependencies": { "@octokit/auth-oauth-device": "^7.1.5", "@octokit/oauth-methods": "^5.1.5", "@octokit/request": "^9.2.3", "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-/R8vgeoulp7rJs+wfJ2LtXEVC7pjQTIqDab7wPKwVG6+2v/lUnCOub6vaHmysQBbb45FknM3tbHW8TOVqYHxCw=="], + + "@octokit/auth-token": ["@octokit/auth-token@5.1.2", "", {}, "sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw=="], + + "@octokit/auth-unauthenticated": ["@octokit/auth-unauthenticated@6.1.3", "", { "dependencies": { "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0" } }, "sha512-d5gWJla3WdSl1yjbfMpET+hUSFCE15qM0KVSB0H1shyuJihf/RL1KqWoZMIaonHvlNojkL9XtLFp8QeLe+1iwA=="], + + "@octokit/core": ["@octokit/core@6.1.6", "", { "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.2.2", "@octokit/request": "^9.2.3", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "before-after-hook": "^3.0.2", "universal-user-agent": "^7.0.0" } }, "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA=="], + + "@octokit/endpoint": ["@octokit/endpoint@10.1.4", "", { "dependencies": { "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA=="], + + "@octokit/graphql": ["@octokit/graphql@8.2.2", "", { "dependencies": { "@octokit/request": "^9.2.3", "@octokit/types": "^14.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA=="], + + "@octokit/oauth-app": ["@octokit/oauth-app@7.1.6", "", { "dependencies": { "@octokit/auth-oauth-app": "^8.1.3", "@octokit/auth-oauth-user": "^5.1.3", "@octokit/auth-unauthenticated": "^6.1.2", "@octokit/core": "^6.1.4", "@octokit/oauth-authorization-url": "^7.1.1", "@octokit/oauth-methods": "^5.1.4", "@types/aws-lambda": "^8.10.83", "universal-user-agent": "^7.0.0" } }, "sha512-OMcMzY2WFARg80oJNFwWbY51TBUfLH4JGTy119cqiDawSFXSIBujxmpXiKbGWQlvfn0CxE6f7/+c6+Kr5hI2YA=="], + + "@octokit/oauth-authorization-url": ["@octokit/oauth-authorization-url@7.1.1", "", {}, "sha512-ooXV8GBSabSWyhLUowlMIVd9l1s2nsOGQdlP2SQ4LnkEsGXzeCvbSbCPdZThXhEFzleGPwbapT0Sb+YhXRyjCA=="], + + "@octokit/oauth-methods": ["@octokit/oauth-methods@5.1.5", "", { "dependencies": { "@octokit/oauth-authorization-url": "^7.0.0", "@octokit/request": "^9.2.3", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0" } }, "sha512-Ev7K8bkYrYLhoOSZGVAGsLEscZQyq7XQONCBBAl2JdMg7IT3PQn/y8P0KjloPoYpI5UylqYrLeUcScaYWXwDvw=="], + + "@octokit/openapi-types": ["@octokit/openapi-types@25.1.0", "", {}, "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA=="], + + "@octokit/openapi-webhooks-types": ["@octokit/openapi-webhooks-types@11.0.0", "", {}, "sha512-ZBzCFj98v3SuRM7oBas6BHZMJRadlnDoeFfvm1olVxZnYeU6Vh97FhPxyS5aLh5pN51GYv2I51l/hVUAVkGBlA=="], + + "@octokit/plugin-paginate-graphql": ["@octokit/plugin-paginate-graphql@5.2.4", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-pLZES1jWaOynXKHOqdnwZ5ULeVR6tVVCMm+AUbp0htdcyXDU95WbkYdU4R2ej1wKj5Tu94Mee2Ne0PjPO9cCyA=="], + + "@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@12.0.0", "", { "dependencies": { "@octokit/types": "^14.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-MPd6WK1VtZ52lFrgZ0R2FlaoiWllzgqFHaSZxvp72NmoDeZ0m8GeJdg4oB6ctqMTYyrnDYp592Xma21mrgiyDA=="], + + "@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@14.0.0", "", { "dependencies": { "@octokit/types": "^14.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-iQt6ovem4b7zZYZQtdv+PwgbL5VPq37th1m2x2TdkgimIDJpsi2A6Q/OI/23i/hR6z5mL0EgisNR4dcbmckSZQ=="], + + "@octokit/plugin-retry": ["@octokit/plugin-retry@7.2.1", "", { "dependencies": { "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "bottleneck": "^2.15.3" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-wUc3gv0D6vNHpGxSaR3FlqJpTXGWgqmk607N9L3LvPL4QjaxDgX/1nY2mGpT37Khn+nlIXdljczkRnNdTTV3/A=="], + + "@octokit/plugin-throttling": ["@octokit/plugin-throttling@10.0.0", "", { "dependencies": { "@octokit/types": "^14.0.0", "bottleneck": "^2.15.3" }, "peerDependencies": { "@octokit/core": "^6.1.3" } }, "sha512-Kuq5/qs0DVYTHZuBAzCZStCzo2nKvVRo/TDNhCcpC2TKiOGz/DisXMCvjt3/b5kr6SCI1Y8eeeJTHBxxpFvZEg=="], + + "@octokit/request": ["@octokit/request@9.2.4", "", { "dependencies": { "@octokit/endpoint": "^10.1.4", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "fast-content-type-parse": "^2.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-q8ybdytBmxa6KogWlNa818r0k1wlqzNC+yNkcQDECHvQo8Vmstrg18JwqJHdJdUiHD2sjlwBgSm9kHkOKe2iyA=="], + + "@octokit/request-error": ["@octokit/request-error@6.1.8", "", { "dependencies": { "@octokit/types": "^14.0.0" } }, "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ=="], + + "@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="], + + "@octokit/webhooks": ["@octokit/webhooks@13.9.1", "", { "dependencies": { "@octokit/openapi-webhooks-types": "11.0.0", "@octokit/request-error": "^6.1.7", "@octokit/webhooks-methods": "^5.1.1" } }, "sha512-Nss2b4Jyn4wB3EAqAPJypGuCJFalz/ZujKBQQ5934To7Xw9xjf4hkr/EAByxQY7hp7MKd790bWGz7XYSTsHmaw=="], + + "@octokit/webhooks-methods": ["@octokit/webhooks-methods@5.1.1", "", {}, "sha512-NGlEHZDseJTCj8TMMFehzwa9g7On4KJMPVHDSrHxCQumL6uSQR8wIkP/qesv52fXqV1BPf4pTxwtS31ldAt9Xg=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], @@ -265,6 +357,8 @@ "@simple-git/argv-parser": ["@simple-git/argv-parser@1.1.1", "", { "dependencies": { "@simple-git/args-pathspec": "^1.0.3" } }, "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw=="], + "@types/aws-lambda": ["@types/aws-lambda@8.10.163", "", {}, "sha512-+4zuoEB3S8RIhimtOFT7zAEk2SbpwrKjjGl9CyYnQR6k08uynxfauIIB0pDU1ssr05F8oyGiETwpn+8eXZMqPw=="], + "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="], "@types/docker-modem": ["@types/docker-modem@3.0.6", "", { "dependencies": { "@types/node": "*", "@types/ssh2": "*" } }, "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg=="], @@ -277,33 +371,29 @@ "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], - "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], - "@types/semver": ["@types/semver@7.8.0", "", {}, "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ=="], - "@types/ssh2": ["@types/ssh2@1.15.6", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-oGdxhBqcRTwSTKFm+9EiKzkNVYRLEFkcW44lhguvBalGJbWfGnDt/ezwSUZc+SF9m9bMc3VyklNAtp7zICjS5w=="], "@types/through": ["@types/through@0.0.33", "", { "dependencies": { "@types/node": "*" } }, "sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@6.21.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.5.1", "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/type-utils": "6.21.0", "@typescript-eslint/utils": "6.21.0", "@typescript-eslint/visitor-keys": "6.21.0", "debug": "^4.3.4", "graphemer": "^1.4.0", "ignore": "^5.2.4", "natural-compare": "^1.4.0", "semver": "^7.5.4", "ts-api-utils": "^1.0.1" }, "peerDependencies": { "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", "eslint": "^7.0.0 || ^8.0.0" } }, "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@7.18.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/type-utils": "7.18.0", "@typescript-eslint/utils": "7.18.0", "@typescript-eslint/visitor-keys": "7.18.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", "ts-api-utils": "^1.3.0" }, "peerDependencies": { "@typescript-eslint/parser": "^7.0.0", "eslint": "^8.56.0" } }, "sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@6.21.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", "@typescript-eslint/typescript-estree": "6.21.0", "@typescript-eslint/visitor-keys": "6.21.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^7.0.0 || ^8.0.0" } }, "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@7.18.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", "@typescript-eslint/typescript-estree": "7.18.0", "@typescript-eslint/visitor-keys": "7.18.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.56.0" } }, "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@6.21.0", "", { "dependencies": { "@typescript-eslint/types": "6.21.0", "@typescript-eslint/visitor-keys": "6.21.0" } }, "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@7.18.0", "", { "dependencies": { "@typescript-eslint/types": "7.18.0", "@typescript-eslint/visitor-keys": "7.18.0" } }, "sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@6.21.0", "", { "dependencies": { "@typescript-eslint/typescript-estree": "6.21.0", "@typescript-eslint/utils": "6.21.0", "debug": "^4.3.4", "ts-api-utils": "^1.0.1" }, "peerDependencies": { "eslint": "^7.0.0 || ^8.0.0" } }, "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@7.18.0", "", { "dependencies": { "@typescript-eslint/typescript-estree": "7.18.0", "@typescript-eslint/utils": "7.18.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, "peerDependencies": { "eslint": "^8.56.0" } }, "sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA=="], - "@typescript-eslint/types": ["@typescript-eslint/types@6.21.0", "", {}, "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg=="], + "@typescript-eslint/types": ["@typescript-eslint/types@7.18.0", "", {}, "sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@6.21.0", "", { "dependencies": { "@typescript-eslint/types": "6.21.0", "@typescript-eslint/visitor-keys": "6.21.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", "minimatch": "9.0.3", "semver": "^7.5.4", "ts-api-utils": "^1.0.1" } }, "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@7.18.0", "", { "dependencies": { "@typescript-eslint/types": "7.18.0", "@typescript-eslint/visitor-keys": "7.18.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^1.3.0" } }, "sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@6.21.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@types/json-schema": "^7.0.12", "@types/semver": "^7.5.0", "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", "@typescript-eslint/typescript-estree": "6.21.0", "semver": "^7.5.4" }, "peerDependencies": { "eslint": "^7.0.0 || ^8.0.0" } }, "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@7.18.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", "@typescript-eslint/typescript-estree": "7.18.0" }, "peerDependencies": { "eslint": "^8.56.0" } }, "sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@6.21.0", "", { "dependencies": { "@typescript-eslint/types": "6.21.0", "eslint-visitor-keys": "^3.4.1" } }, "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@7.18.0", "", { "dependencies": { "@typescript-eslint/types": "7.18.0", "eslint-visitor-keys": "^3.4.3" } }, "sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg=="], "@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="], @@ -337,8 +427,12 @@ "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], + "before-after-hook": ["before-after-hook@3.0.2", "", {}, "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A=="], + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + "bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], + "brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -405,9 +499,9 @@ "doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="], - "drizzle-kit": ["drizzle-kit@0.28.1", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-JimOV+ystXTWMgZkLHYHf2w3oS28hxiH1FR0dkmJLc7GHzdGJoJAQtQS5DRppnabsRZwE2U1F6CuezVBgmsBBQ=="], + "drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="], - "drizzle-orm": ["drizzle-orm@0.36.4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=3", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/react": ">=18", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "react": ">=18", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/react", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "knex", "kysely", "mysql2", "pg", "postgres", "react", "sql.js", "sqlite3"] }, "sha512-1OZY3PXD7BR00Gl61UUOFihslDldfH4NFRH2MbP54Yxi0G/PKn4HfO65JYZ7c16DeP3SpM3Aw+VXVG9j6CRSXA=="], + "drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -423,9 +517,7 @@ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], - "esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="], - - "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -453,6 +545,8 @@ "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "fast-content-type-parse": ["fast-content-type-parse@2.0.1", "", {}, "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], @@ -487,6 +581,8 @@ "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], @@ -621,6 +717,8 @@ "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + "octokit": ["octokit@4.1.4", "", { "dependencies": { "@octokit/app": "^15.1.6", "@octokit/core": "^6.1.5", "@octokit/oauth-app": "^7.1.6", "@octokit/plugin-paginate-graphql": "^5.2.4", "@octokit/plugin-paginate-rest": "^12.0.0", "@octokit/plugin-rest-endpoint-methods": "^14.0.0", "@octokit/plugin-retry": "^7.2.1", "@octokit/plugin-throttling": "^10.0.0", "@octokit/request-error": "^6.1.8", "@octokit/types": "^14.0.0", "@octokit/webhooks": "^13.8.3" } }, "sha512-cRvxRte6FU3vAHRC9+PMSY3D+mRAs2Rd9emMoqp70UGRvJRM3sbAoim2IXRZNNsf8wVfn4sGxVBHRAP+JBVX/g=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], @@ -729,6 +827,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "toad-cache": ["toad-cache@3.7.4", "", {}, "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "tree-sitter": ["tree-sitter@0.21.1", "", { "dependencies": { "node-addon-api": "^8.0.0", "node-gyp-build": "^4.8.0" } }, "sha512-7dxoA6kYvtgWw80265MyqJlkRl4yawIjO7S5MigytjELkX43fV2WsAXzsNfO7sBpPPCF5Gp0+XzHk0DwLCq3xQ=="], @@ -741,6 +841,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "tsx": ["tsx@4.23.13", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw=="], + "turbo": ["turbo@1.13.4", "", { "optionalDependencies": { "turbo-darwin-64": "1.13.4", "turbo-darwin-arm64": "1.13.4", "turbo-linux-64": "1.13.4", "turbo-linux-arm64": "1.13.4", "turbo-windows-64": "1.13.4", "turbo-windows-arm64": "1.13.4" }, "bin": { "turbo": "bin/turbo" } }, "sha512-1q7+9UJABuBAHrcC4Sxp5lOqYS5mvxRrwa33wpIyM18hlOCpRD/fTJNxZ0vhbMcJmz15o9kkVm743mPn7p6jpQ=="], "turbo-darwin-64": ["turbo-darwin-64@1.13.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-A0eKd73R7CGnRinTiS7txkMElg+R5rKFp9HV7baDiEL4xTG1FIg/56Vm7A5RVgg8UNgG2qNnrfatJtb+dRmNdw=="], @@ -765,6 +867,10 @@ "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "universal-github-app-jwt": ["universal-github-app-jwt@2.2.2", "", {}, "sha512-dcmbeSrOdTnsjGjUfAlqNDJrhxXizjAz94ija9Qw8YkZ1uu0d+GoZzyH+Jb9tIIqvGsadUfwg+22k5aDqqwzbw=="], + + "universal-user-agent": ["universal-user-agent@7.0.3", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="], + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], @@ -805,7 +911,7 @@ "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], - "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.3", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg=="], + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], @@ -825,6 +931,8 @@ "ora/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -889,6 +997,58 @@ "ora/strip-ansi/ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], + "inquirer/ora/cli-cursor/restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], "inquirer/ora/cli-cursor/restore-cursor/onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], diff --git a/docker-compose.yml b/docker-compose.yml index 7f72095..7d36920 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,9 @@ services: postgres: image: postgres:16 + container_name: driftlock-postgres ports: - - "5432:5432" + - "127.0.0.1:5432:5432" environment: POSTGRES_USER: driftlock POSTGRES_PASSWORD: driftlock diff --git a/docs/architecture.md b/docs/architecture.md deleted file mode 100644 index e7cbc40..0000000 --- a/docs/architecture.md +++ /dev/null @@ -1,84 +0,0 @@ -# Driftlock — Architecture - -## System overview - -Driftlock is a GitHub App + background worker. It watches repos where it's installed, extracts API usage from static analysis and sandbox test runs, diffs inferred specs over time, and opens PRs when drift is detected. - -## Components - -### 1. GitHub App -- OAuth/webhook handler for installation events. -- App-level permissions: read repo contents, read PRs, write PRs (create branches, open PRs). -- Same permission model as CodeRabbit, Dependabot, Renovate. - -### 2. Usage extractor (static analysis) -- Parses codebase to find call sites for tracked third-party APIs. -- Extracts: endpoint paths, HTTP methods, request params/body fields, response fields accessed. -- Outputs a normalized "inferred spec" per call site. - -### 3. Test classifier -- Inspects test files to determine whether a test hits a real sandbox/test-mode endpoint or mocks the HTTP layer. -- Differentiates: (a) monitored (sandbox-hitting), (b) tested-but-blind (mocked), (c) untested. - -### 4. Sandbox prober -- Runs the customer's test suite (one command) against their sandbox credentials. -- Captures real request/response payloads. -- Stores snapshots keyed by call site + timestamp. -- **Safety:** skips non-idempotent endpoints by default unless explicitly whitelisted. - -### 5. Drift detector -- Compares latest snapshot against the previous one. -- Identifies: added/removed fields, renamed fields, type changes, status code changes, new required params. -- Confidence scoring to reduce false positives. - -### 6. PR generator -- On drift detection, creates a fresh branch. -- Applies a proposed fix (field rename, default value addition, type coercion, etc.). -- Opens a PR with: what changed, why it changed, suggested fix. -- Deletes the branch immediately after merge or close (CodeRabbit hygiene). - -### 7. Coverage reporter -- Per-call-site status: monitored / tested-but-blind / untested. -- Exposed in-app or via a status check on the PR. - -## Data model (simplified) - -```text -Installation - - repo, owner, app_id, webhook_secret - -CallSite - - repo_id, file_path, method, endpoint - - inferred_spec_json, last_checked_at - -Snapshot - - call_site_id, captured_at - - request_shape_json, response_shape_json - -DriftEvent - - call_site_id, detected_at - - old_snapshot_id, new_snapshot_id - - diff_summary, suggested_fix_json - - pr_number (nullable, until opened) -``` - -## v1 scope - -- Single vendor: **Stripe**. -- Single language/framework target (to be decided; likely TypeScript/Node given target audience). -- GitHub App only. No GitLab/Bitbucket/GitHub Enterprise in v1. -- Suggest-only PRs. No auto-merge. - -## Known hard problems - -- **Mocked vs. sandbox tests** — looks identical from the outside unless you trace whether the test made a network call. Detecting this reliably is core infra. -- **Coverage ceiling** — if it's not tested against a real sandbox, it's invisible. Say this plainly. -- **Non-idempotent endpoints** — POST /charges can't be replayed safely without explicit sandbox handling. -- **Spec inference precision** — dynamic dispatch, wrapper SDKs, and generated clients can obscure the actual HTTP surface. - -## Out of scope for v1 - -- Auto-merge / auto-apply. -- Multi-vendor support. -- Passive traffic monitoring / proxying. -- New-feature discovery (changelog/docs crawling). diff --git a/docs/call-prep-kai.md b/docs/call-prep-kai.md deleted file mode 100644 index ed43293..0000000 --- a/docs/call-prep-kai.md +++ /dev/null @@ -1,56 +0,0 @@ -# Call Prep: Kai Takami (YC Founder, domu.ai) - -## Before the call - -- **Know domu.ai.** Spend 15 minutes on their site, blog, Twitter. You want to ask informed questions, not generic "how was YC" questions. If you can reference something specific about their journey, it shows you did your homework. -- **Have your story in 30 seconds.** "I'm building Dependabot for API changes. I worked at AWS, saw 30% of downtime come from unnoticed external API changes. I've worked with 50+ vendors since. The product watches your API contracts via sandbox test runs and opens PRs when vendors change something underneath you." Practice this until it's natural. -- **Have your hard question ready.** Don't save it for the end. Ask it first while you have full attention. - -## What to ask - -### YC process (high signal) - -1. **"What did you put in your application that you think actually got you in?"** Not "how was YC" — he's heard that a thousand times. You want the specific thing that worked. - -2. **"What question did YC partners ask in the interview that you didn't expect?"** This tells you what blind spots to fix before you apply. - -3. **"If you were applying today with a dev-tools idea, what would you do differently?"** Lets him give advice specific to your space, not just general startup advice. - -### Validation (what you actually need) - -4. **"How many customer conversations did you have before you built anything, and what did you learn from them?"** You need to know if the Mom Test approach (talk to 10+ people about the problem, not your solution) actually worked for him or if he did something different. - -5. **"What was the first thing you built that proved people wanted this?"** You want to know what "validation" looks like in practice, not in theory. - -6. **"How did you get your first 3 design partners?"** This is the hardest step for every founder. Specific tactics > general advice. - -### Product / GTM (where you're uncertain) - -7. **"How do you think about starting with one vendor (Stripe) vs. supporting many from day one?"** You've decided single-vendor. Get his read on whether that was right or if you should expand faster. - -8. **"What's the hardest thing about selling to engineering teams?"** Engineering buyers are different from sales/marketing buyers. He'll know. - -9. **"When did you know it was working — what was the signal?"** Not "when did you get revenue" but what *signal* told you this was real. - -### Things to avoid asking - -- Don't ask "should I do YC or bootstrap" — he's biased and you already want YC. -- Don't ask generic questions you can Google ("what's a SAFE"). -- Don't pitch him for 10 minutes. Ask questions, listen, then briefly explain Driftlock when he asks "so what are you building?" -- Don't ask for an intro to YC partners. That's not how it works. - -## How to approach the call - -**First 2 minutes:** Be human. Brief intro — who you are, what you're building, why you wanted to talk to him specifically. Not a pitch, a conversation starter. - -**Minutes 3-15:** Ask your questions. Listen more than you talk. If he says something interesting, follow up on it instead of moving to the next question. The best insights come from follow-ups, not from a checklist. - -**Minutes 15-20:** Ask "is there anything about Driftlock that you'd push back on?" or "what's the thing I'm not seeing?" YC founders are blunt if you give them permission to be. - -**Last minute:** Ask if there's anything he wishes he'd known earlier. Thank him. Offer to help with anything he's working on (even if you can't yet — the gesture matters). - -## After the call - -- Send a thank-you message within 1 hour. Short, specific — reference something he said, not just "thanks for your time." -- Write down every insight immediately. You'll forget half of it by tomorrow. -- If he gives you a specific action (e.g., "talk to these people"), do it within 48 hours and tell him you did it. This is how you build a real relationship, not just a one-time call. diff --git a/docs/competitive-analysis.md b/docs/competitive-analysis.md deleted file mode 100644 index 6331c29..0000000 --- a/docs/competitive-analysis.md +++ /dev/null @@ -1,222 +0,0 @@ -# Competitive Analysis: Self-Maintaining APIs - -> **Last updated:** September 2026 -> **Status:** Active research — we reviewed these projects silently to avoid repeating their mistakes and to understand the landscape before building. - ---- - -## Why this exists - -The "self-maintaining APIs" concept is gaining traction. YC published an RFS for it. Multiple teams are building solutions. We tracked their development quietly to: - -1. Understand what approaches work and what don't -2. Avoid architectural mistakes others have made -3. Identify gaps we can fill -4. Reference their work transparently - -This is not a threat analysis — it's market intelligence. - ---- - -## The landscape - -| Project | Stage | Approach | Language | LLM | Traction | -|---------|-------|----------|----------|-----|----------| -| **DriftLock** (us) | Active development | Static analysis + sandbox snapshots + diff | TypeScript/Bun | OpenAI | Building | -| **Ripple** (Aakash2408) | Demo stage | Spec diff + consumer finding + fix gen | Go (inferred) | Claude | 0 stars, private core | -| **HelpPR** (pedapudi-pavansai) | Most mature | OpenAPI monitoring + static analysis + LLM | Python + React | Claude | 0 stars, 28 commits | -| **banningwill-AdAstra** | Prototype | AST visitor + fix gen | Python | Claude | 0 stars, 2 commits | -| **RajaDheeraj** | Prototype | Agent-based (regex + LLM) | Python + React | Gemini | 0 stars, 3 commits | -| **Ability.ai** | Platform play | Agent runtime + knowledge graph | N/A | Multi-model | 544 stars (Trinity) | - ---- - -## Detailed analysis - -### 1. Ripple (Aakash2408) - -**GitHub:** https://github.com/Aakash2408/ripple (private core) -**Demo repos:** https://github.com/Aakash2408/ripple-demo-api, https://github.com/Aakash2408/ripple-demo-frontend, https://github.com/Aakash2408/ripple-payments-api, https://github.com/Aakash2408/ripple-sdk-node, https://github.com/Aakash2408/ripple-sdk-java, https://github.com/Aakash2408/ripple-sdk-python - -**What they built:** -- Core engine (Go, private) that diffs old vs new API specs -- 10 diff engines (OpenAPI, Protobuf, GraphQL, DB/Prisma, AsyncAPI, Avro, tRPC, Thrift, JSON Schema, Smithy) -- 5-strategy consumer finder (grep, import graph, git co-change history, playbooks, multi-invoker) -- Template-based + LLM (Claude) fix generation -- GitHub App + GitHub Action + Docker self-hosted agent - -**What we learned from them:** -- The **git co-change history** strategy is clever — if two files always change together in commits, they're likely coupled. We should consider this. -- Their **ensemble consumer-finding** approach (5 strategies) is more robust than single-strategy detection -- Supporting 10 contract types is ambitious but unverified — all demos only show OpenAPI -- The **PropBench** benchmark (268 scenarios) is self-created — no independent validation - -**Gaps we identified:** -- Core source code is private — no independent audit possible -- All demo repos are trivial (1-2 files, 2-5KB each) -- No auto-generated PRs have been merged — all remain open -- Solo founder, no team -- No production validation or case studies -- Landing page returns 404 - -**Our takeaway:** Their architecture is interesting but unproven at scale. We should focus on production-ready tooling rather than demo-stage breadth. - ---- - -### 2. HelpPR (pedapudi-pavansai) - -**GitHub:** https://github.com/pedapudi-pavansai/HelpPR - -**What they built:** -- Full platform: React frontend + FastAPI backend + MongoDB -- OpenAPI spec monitoring and breaking change detection -- Deterministic static analysis + bounded LLM reasoning (Claude) -- AWS ECS Fargate deployment with Terraform -- GitHub App integration - -**What we learned from them:** -- Their **deterministic-first, LLM-second** approach is smart — scan mechanically first, only use AI for ambiguous cases -- The **backend module structure** (detector, diff, languages, llm, patcher, pipeline, scanner, watcher) is well-organized -- Using MongoDB for persistence makes sense for their scale -- The **watcher** module for upstream API monitoring is a feature we haven't built yet - -**Gaps we identified:** -- Multi-language support is listed as "future improvement" — not built yet -- AST-based code transformations not implemented -- No evidence of production use -- Most complex architecture — higher barrier to entry -- 0 stars, no community adoption - -**Our takeaway:** Their deterministic-first approach validates our static analysis strategy. We should consider adding an upstream API watcher. - ---- - -### 3. banningwill-AdAstra/self-maintaining-apis - -**GitHub:** https://github.com/banningwill-AdAstra/self-maintaining-apis - -**What they built:** -- Python AST visitor that detects deprecated OpenAI SDK patterns -- Claude-powered fix generation -- GitHub Actions integration (push + weekly schedule) -- 4 detection rules for OpenAI SDK deprecations - -**What we learned from them:** -- **AST-based detection** is more accurate than regex — it understands code structure -- Running scans on a **weekly schedule** (even without code changes) catches upstream deprecations -- The **three modes** (detect, detect+diff, detect+apply) give users control - -**Gaps we identified:** -- Python-only detection -- Only 4 OpenAI SDK rules implemented -- No validation of LLM-generated fixes -- Very early stage (2 commits) - -**Our takeaway:** AST-based detection is worth considering for higher accuracy. Weekly scheduled scans are a feature we should add. - ---- - -### 4. RajaDheeraj/self-maintaining-apis - -**GitHub:** https://github.com/RajaDheeraj/self-maintaining-apis - -**What they built:** -- FastAPI + React agent-based system -- Google Gemini function-calling for autonomous code search and fix -- Strict fix validation (revert + diff check) -- Single hardcoded migration pattern (get_user → retrieve_user) - -**What we learned from them:** -- Their **strict validation** approach (revert the edit, diff against original) ensures fixes don't introduce new bugs -- The **agent-based architecture** (LLM autonomously searches, reads, proposes) is ambitious but risky - -**Gaps we identified:** -- Hardcoded to a single migration pattern -- Python-only -- No CI/CD integration -- 12-second rate-limit sleeps (Gemini free tier) -- 3 commits, 0 stars - -**Our takeaway:** Strict fix validation is a good idea. Agent-based approaches are too unpredictable for production use. - ---- - -### 5. Ability.ai - -**Website:** https://www.ability.ai -**Article:** https://www.ability.ai/blog/self-maintaining-apis-downtime - -**What they built:** -- Trinity: Open-source (Apache 2.0) AI agent runtime platform -- Cornelius: Self-improving cognitive core (knowledge graph) -- Self-maintaining APIs as a use case on their platform - -**What we learned from them:** -- The **30% downtime stat** they cite is the same one in our YC application — it's becoming the standard pitch -- Their **open-core model** (open source + enterprise features) is a proven business model -- The **MCP integration** (90+ tools) and channel integrations (Slack, WhatsApp) represent substantial platform work - -**Gaps we identified:** -- Self-maintaining APIs appears to be a vision/roadmap item, not a shipped product -- No published pricing -- SOC 2 still "in progress" -- 544 stars suggests early traction - -**Our takeaway:** They're a platform play, not a direct competitor. Their article validates the problem space. - ---- - -## Common patterns across all projects - -1. **Everyone cites the 30% downtime stat** — it's becoming the standard pitch for this space -2. **No project has meaningful traction** — all have 0 stars (except Ability.ai's platform) -3. **Python dominates** — most competitors are Python-based -4. **LLM-powered fixes are universal** — everyone uses Claude or Gemini for complex fixes -5. **Deterministic detection first** — the better projects scan mechanically before using AI -6. **No production validation** — nobody has case studies or real users yet - ---- - -## What we do differently - -| Dimension | Competitors | DriftLock | -|-----------|-------------|-----------| -| **Language** | Mostly Python | TypeScript/Bun (faster, type-safe) | -| **Detection** | Spec diffing or regex | Static analysis + sandbox snapshots | -| **Fix validation** | Some (RajaDheeraj) | Built-in (snapshot diffing) | -| **Test classification** | None | Monitored/blind/untested | -| **Persistence** | In-memory or MongoDB | PostgreSQL + Drizzle ORM | -| **Architecture** | Monolithic or agent-based | Modular monorepo | -| **Approach** | Vendor provides specs | We infer from code + sandbox | - ---- - -## Gaps we can fill - -1. **Test classification** — nobody else distinguishes mocked vs real tests -2. **Sandbox snapshots** — nobody captures actual request/response shapes from test runs -3. **TypeScript/Bun** — faster development cycle, better type safety -4. **Production-ready** — modular architecture, proper database, CI/CD from day one -5. **Vendor-agnostic** — we don't need vendors to publish specs - ---- - -## What we should consider adopting - -1. **Git co-change history** (from Ripple) — detect coupled files -2. **AST-based detection** (from banningwill-AdAstra) — more accurate than regex -3. **Weekly scheduled scans** (from banningwill-AdAstra) — catch upstream deprecations -4. **Strict fix validation** (from RajaDheeraj) — ensure fixes don't introduce bugs -5. **Deterministic-first approach** (from HelpPR) — scan mechanically, use AI only for ambiguous cases - ---- - -## References - -- Ripple: https://github.com/Aakash2408/ripple -- Ripple demo repos: https://github.com/Aakash2408/ripple-demo-api, https://github.com/Aakash2408/ripple-demo-frontend, https://github.com/Aakash2408/ripple-payments-api, https://github.com/Aakash2408/ripple-sdk-node, https://github.com/Aakash2408/ripple-sdk-java, https://github.com/Aakash2408/ripple-sdk-python -- HelpPR: https://github.com/pedapudi-pavansai/HelpPR -- banningwill-AdAstra: https://github.com/banningwill-AdAstra/self-maintaining-apis -- RajaDheeraj: https://github.com/RajaDheeraj/self-maintaining-apis -- Ability.ai: https://www.ability.ai/blog/self-maintaining-apis-downtime -- YC RFS: https://www.youtube.com/shorts/c3TxAUir2R8 diff --git a/docs/mvp.md b/docs/mvp.md deleted file mode 100644 index 18d59a3..0000000 --- a/docs/mvp.md +++ /dev/null @@ -1,77 +0,0 @@ -# Driftlock — MVP Doc - -## One-liner -Your vendor changes something. Your code breaks silently. You find out at 2am. - -Driftlock notices the change before you do, opens a PR with the fix, and you review and merge. No vendor cooperation needed. - -## The problem - -Stripe renames a field. Twilio deprecates an endpoint. Shopify changes a response type. You don't find out until production breaks. - -- Changelogs don't get read. Docs drift from reality. -- The cost lands on the *consumer*, not the vendor. -- 30%+ of downtime at a major cloud provider was traced to unnoticed external API changes. - -## What makes it hard - -- **Knowing what actually changed.** Not "Stripe updated" — "field X was renamed to Y in this endpoint." -- **Knowing who it affects.** Only repos that call that specific endpoint with that specific field. -- **Suggesting the right fix.** Not "something changed" — "replace `charge.amount` with `charge.value` on line 42." - -## Why now - -Agentic coding tools (Claude Code, Devin, CodeRabbit, Greptile) have normalized giving an external tool write-adjacent access to a codebase. The trust curve has been crossed. We're applying the same trust model to a new trigger source: third-party API drift. - -## Who pays - -**Consumers/integrators of third-party APIs** — engineering teams who depend on external services and bear the cost when those services change. Not the API vendors themselves. This avoids needing any vendor cooperation, spec publication, or buy-in. - -## The core loop - -1. **Install** — GitHub App, repo access only (same footprint as CodeRabbit/Renovate/Dependabot). -2. **Discover usage** — static analysis of the codebase to find every call site touching a tracked third-party API (endpoints, params sent, fields read from responses). -3. **Find real signal, not mocks** — identify which tests actually hit a live sandbox/test-mode endpoint vs. tests that mock the API call. Only sandbox-hitting tests produce real, checkable data. -4. **Snapshot the spec** — run the sandbox-hitting tests (one command), capture actual request/response shapes, store as the current "inferred spec" for that call site. No vendor-published spec required. -5. **Re-check on a schedule** — re-run the same command periodically, capture the new response shape. -6. **Diff** — compare new snapshot against the last one. A shape change = drift. -7. **Suggest, don't apply** — on drift, open a PR from a fresh branch with a proposed fix. Human reviews and merges. Branch is deleted immediately after merge (CodeRabbit-style hygiene). -8. **Report coverage** — separately, tell the customer which of their API call sites are: (a) monitored (hit by a real sandbox test), (b) tested but blind (test exists but mocks the call — looks covered, isn't), (c) untested (no visibility at all). - -## Explicit scope boundaries for v1 - -- **Suggest-only.** No auto-merge, no auto-apply, from day 1. Trust is earned, not assumed. -- **No vendor cooperation needed or expected.** We never ask a vendor to publish anything. -- **Coverage = whatever the customer's tests already safely exercise.** We do not discover unused endpoints or "features you're not using yet" — that's changelog/docs crawling, out of scope for v1. -- **No passive production traffic capture.** Too heavy an ask for an early install. Static analysis + scheduled sandbox test runs only. -- **Non-idempotent endpoints need explicit handling.** Anything with real side effects (charges, emails sent, etc.) is only safe to re-check if it's already hitting a true sandbox/test-mode credential in the customer's existing tests — never re-run against production. - -## First target vendor - -**Stripe** — mature test mode, huge installed base, predictable API versioning, plenty of design partners. - -## Target user (v1 design partners) - -Small-to-mid engineering teams with: -- An existing CI test suite that hits Stripe's test mode (not fully mocked). -- At least one prior incident/pain point from an unnoticed Stripe change. - -## Success criteria for MVP - -- Correctly detect at least one real historical Stripe breaking change or field deprecation, retroactively, against a real design partner's test history. -- Produce a coverage report a design partner says is accurate (matches their own sense of what's tested vs. mocked). -- Get one design partner to accept and merge a suggested-fix PR. -- False positive rate low enough that a design partner doesn't disable the bot in week one. - -## Known hard problems (don't hand-wave these in the pitch) - -- **Mocked vs. sandbox tests look identical from the outside** unless you actually trace whether the test made a network call. Detecting this reliably is core infra, not a nice-to-have. -- **Coverage is a hard ceiling, not a soft one.** If it's not tested against a real sandbox, it's invisible. Say this plainly to customers. -- **New-feature discovery is a different product.** Resist scope creep here for v1. - -## Out of scope for v1 - -- Auto-merge / auto-apply. -- Multi-vendor support (start with one vendor, prove the loop, then generalize). -- Passive traffic monitoring / proxying. -- Discovering unused API surface / new features. diff --git a/docs/product-engineering.md b/docs/product-engineering.md deleted file mode 100644 index ce870e9..0000000 --- a/docs/product-engineering.md +++ /dev/null @@ -1,282 +0,0 @@ -# Driftlock — Product Engineering - -## Core detection loop - -``` -GitHub App install → discover call sites → classify tests → -probe sandbox → diff specs → open PR → report coverage -``` - -The engineering challenge is making each arrow reliable enough that a design partner trusts it in week one. - -What makes it hard (and interesting): - -1. **Knowing what actually changed** — not just "Stripe updated" but "field X was renamed to Y in this specific endpoint." You need to map a vendor-level change onto a specific call site in a specific language/SDK. -2. **Knowing who it affects** — only repos that call that specific endpoint with that specific field. You can't blast every Stripe customer with every drift; you need per-repo, per-call-site targeting. -3. **Suggesting the right fix** — not just "something changed" but "replace `charge.amount` with `charge.value` on line 42." This is the difference between a useful bot and noise. - -Every other section in this doc is a sub-problem of one of these three. - ---- - -## 1. Static usage extraction - -**Goal:** find every call site that touches Stripe, extract the endpoint, params sent, and response fields accessed. - -### Approach for Stripe (v1) - -Stripe's Node SDK (`stripe.*`) is a thin wrapper. The actual HTTP surface is: - -```js -// Pattern 1: direct SDK call -const charge = await stripe.charges.create({ amount: 1000, currency: 'usd' }); -console.log(charge.status); - -// Pattern 2: via resource access -const customer = await stripe.customers.retrieve('cus_123'); - -// Pattern 3: list/search -const invoices = await stripe.invoices.list({ customer: 'cus_123', limit: 10 }); -``` - -For v1, we target the **Node.js SDK** only. Extraction strategy: - -1. **AST parse** — use `@babel/parser` or `tree-sitter` to find `MemberExpression` chains where the root is an identifier named `stripe` (or destructured aliases like `const { charges } = stripe`). -2. **Argument inference** — extract the literal/object passed as the first argument. This gives us the request shape the code *expects* to send. -3. **Response field tracking** — find property accesses on the returned value. `charge.status`, `invoice.lines.data[0].amount`, etc. - -### Output format per call site - -```json -{ - "file": "src/services/billing.ts", - "line": 42, - "method": "stripe.charges.create", - "endpoint": "/v1/charges", - "requestShape": { "amount": "number", "currency": "string", "customer": "string?" }, - "responseFields": ["status", "id", "amount", "currency"], - "testFiles": ["src/services/billing.test.ts"] -} -``` - -### Known limitations - -- Destructured aliases (`const s = stripe`) require scope analysis. Fall back to file-level regex for aliases we can't resolve. -- Dynamic SDK methods (`stripe[methodName](...)`) can't be statically resolved. Skip with a warning. -- Wrapper abstractions (`billingService.createCharge(...)`) that internally call Stripe will be missed unless we trace through the wrapper. Accept this coverage gap in v1. - ---- - -## 2. Test classification (mock vs. sandbox) - -**Goal:** know which tests actually hit Stripe's test-mode API vs. mock the HTTP layer. - -### Detection signals (ranked by reliability) - -| Signal | Reliable? | How to detect | -|--------|-----------|---------------| -| `jest.mock('stripe')` or `vi.mock('stripe')` | High | Parse test file for mock declarations | -| MSW/nock interceptors registered | High | Check for `msw`, `nock`, `fetch-mock` setup in test file | -| `stripe.setApiKey('sk_test_...')` present | Medium | Means real credentials are configured, but doesn't prove the test runs un-mocked | -| No mock signal + test command runs | High (empirical) | Run the test suite with a network proxy; if no traffic to `api.stripe.com` exits, it's blind | - -### v1 heuristic (good enough) - -Combine signals: - -1. If any mock library is detected in the test file → **tested-but-blind**. -2. If the test command, run with our proxy, produces zero traffic to `api.stripe.com` → **untested** (or all tests in the suite are mocked). -3. Otherwise → **monitored**. - -### The proxy trick - -To empirically detect sandbox traffic, run the test command with: - -```bash -HTTPS_PROXY=http://localhost:8888 npm test -``` - -Our lightweight Node proxy records all outbound HTTPS traffic. After the test run: - -- Traffic to `api.stripe.com` with `Authorization: Bearer sk_test_...` → monitored. -- No traffic → blind or mocked. - -This is more reliable than static mock detection alone, because it catches: -- Tests that mock at the `fetch` level (not `stripe` module level) -- Tests that use environment-based flagging to skip external calls -- Test files that import a shared mock setup - ---- - -## 3. Sandbox probing - -**Goal:** run the customer's test suite (or a probe command they nominate) against their sandbox credentials and capture request/response shapes. - -### How it works - -1. Customer nominates a command: e.g., `npm run test:integration` or `npm test -- --grep "stripe"` -2. We run it in a fresh CI job with: - - Their repo checked out - - Their env vars (`STRIPE_SECRET_KEY=sk_test_...`, etc.) injected from their GitHub Secrets - - `HTTPS_PROXY` pointing to our local recorder -3. The proxy logs every request/response pair keyed by the test file + line number (or call site if we can instrument the SDK). - -### Safety: non-idempotent endpoints - -Stripe's POST endpoints (charges, customers, invoices) have real side effects even in test mode — they consume test data, can hit limits, and may trigger webhooks. - -**v1 policy:** -- Only probe GET endpoints by default (retrieve, list, retrieve-upcoming-invoice). -- POST/PUT/DELETE endpoints are classified as "non-idempotent" and skipped unless: - - The test explicitly uses Stripe test mode (`sk_test_*` key), **and** - - The test suite has a documented "safe to replay" flag or the customer explicitly whitelists the endpoint in a config file we provide. - -### Credential handling - -- Never persist raw API keys. Pull from GitHub Secrets at job runtime. -- Rotate/expire keys are the customer's responsibility. -- We only need read-level access for GET probes; write access is only needed if the customer explicitly opts into POST probing. - ---- - -## 4. Schema diffing - -**Goal:** compare two snapshots of the same endpoint's request/response shape and classify changes. - -### What we diff - -| Layer | What we track | -|-------|--------------| -| Request | Param names, types, required vs. optional | -| Response | Top-level fields, nested objects, array element shapes, status codes | -| Both | Added fields, removed fields, type changes (string → number), optionality changes | - -### Diff algorithm - -Use **JSON Schema** as the intermediate representation: - -1. Infer a JSON Schema from each snapshot (use a library like `json-schema-generator` or hand-roll for Stripe's known shapes). -2. Diff the two schemas using a structural diff (not line-by-line). -3. Classify each diff as: - - **BREAKING:** field removed, field type changed, field became required, status code added/removed - - **NON-BREAKING:** field added as optional, enum expanded - - **UNKNOWN:** ambiguous change (e.g., array element shape changed but we only saw one element) - -### False positive mitigation - -- Require the same field to change in **N consecutive snapshots** before flagging (configurable, default 2). -- For TypeScript codebases, cross-reference against the codebase's type definitions: if the code already uses the new shape, it's a false positive (the developer already adapted). -- Whitelist known-safe Stripe deprecations (e.g., `source` → `payment_method` migration that Stripe announced and documented). - ---- - -## 5. PR generation - -**Goal:** produce a PR that a human can review and merge in under 2 minutes. - -### PR structure - -``` -Title: [Driftlock] Stripe API drift detected: charge.status changed from string to nullable string - -Body: -## What changed -Stripe changed the `status` field on `POST /v1/charges` responses. -- Before: `string` (e.g., "succeeded", "failed") -- After: `string | null` - -## Where you use it -src/services/billing.ts:42 — `const charge = await stripe.charges.create(...)` -src/services/billing.ts:45 — `console.log(charge.status)` - -## Suggested fix -Add a null check before using `charge.status`: - -\`\`\`diff -- console.log(charge.status) -+ console.log(charge.status ?? 'unknown') -\`\`\` - -## Coverage note -This endpoint is monitored (test: src/services/billing.test.ts, hits Stripe test mode). -``` - -### Fix generation strategy - -For v1 Stripe, build a small **fix template library** keyed by diff pattern: - -| Diff pattern | Suggested fix template | -|-------------|----------------------| -| field removed | Remove access, or use `?.` optional chaining | -| field type widened (e.g., string → string \| null) | Add null coalescing (`??`) or explicit null check | -| field type narrowed (e.g., string → enum) | Add switch/guard for new enum values | -| new required request param | Add param with sensible default or mark as required in your input type | -| status code added | Add new case to error handling switch | - -This is Stripe-specific. Generalizing to N vendors is post-v1 work. - -### Branch hygiene - -- Branch name: `driftlock/{call-site-hash}-{timestamp}` -- Delete immediately after PR is merged or closed (webhook listener on `pull_request` events). -- One PR per drift event, not batched. - ---- - -## 6. Infrastructure - -### Stack recommendation - -| Layer | Choice | Reason | -|-------|--------|--------| -| GitHub App server | Next.js API routes or plain Express | Octokit works everywhere; Next.js gives you easy deploys on Vercel | -| Background workers | Inngest or BullMQ + Redis | Event-driven, handles webhook retries and scheduled probe runs | -| Database | PostgreSQL | Call sites, snapshots, drift events, installations | -| Proxy (sandbox probing) | Custom Node HTTP(S) proxy | Lightweight, single-purpose | -| Queue for probe jobs | Same as workers | One queue, multiple consumers | -| Hosting | Vercel (app) + Fly/Render (workers + proxy) | Separate the stateless app from the stateful worker | - -### Data retention - -- Snapshots: keep last 2 per call site (current + previous). Older snapshots are cold and can be archived. -- Drift events: keep indefinitely (audit trail). -- Coverage reports: regenerate on demand from current snapshot + test classification state. - -### Cost model for running a probe - -A typical probe job: -- Clones the repo: ~30s, ~100MB bandwidth -- Installs deps: ~60s, cached between runs -- Runs tests: 30s–5min (customer's existing suite, we don't add significant overhead) -- Proxy overhead: negligible - -Aim for **<5 min total** for a probe job. If the customer's full suite is slow, let them nominate a subset (e.g., `npm test -- --grep "stripe"`). - ---- - -## 7. v1 implementation priority - -Build in this order: - -1. **GitHub App skeleton** — install webhook, repo read access, PR creation. Prove the OAuth + permission flow. -2. **Stripe static extractor** — AST parse for `stripe.*` calls. Output call sites. Run against 3–5 real open-source repos and measure precision/recall. -3. **Probe proxy** — Node HTTPS proxy that records request/response pairs. Run a customer's Stripe test suite through it end-to-end. -4. **Snapshot storage** — persist captured shapes. Build the "previous vs. current" diff UI. -5. **Diff classifier** — JSON Schema diff with breaking/non-breaking classification. -6. **PR generator** — template-based fix suggestions for Stripe-specific diff patterns. -7. **Coverage reporter** — per-call-site status, surfaced in PR comment or status check. -8. **Scheduler** — cron-like re-probing. Start with manual trigger, then add scheduled runs. - ---- - -## 8. What to instrument from day one - -Logging that will save you weeks of debugging: - -- Every call site discovered (file, line, method, inferred endpoint) -- Every probe run: command, duration, exit code, traffic captured count -- Every drift event: diff summary, confidence score, whether a PR was opened -- PR lifecycle: opened → merged/closed, time-to-merge, time-to-close -- False positive signals: PR opened but customer comments "not a real issue" - -This data is also your YC demo gold — "we detected 12 drifts across 8 design partners, 10 of which were real, here's the PRs." diff --git a/docs/yc-application.md b/docs/yc-application.md deleted file mode 100644 index 76f6345..0000000 --- a/docs/yc-application.md +++ /dev/null @@ -1,88 +0,0 @@ -# Driftlock — YC Application Draft - -Based on the W26/S26 application format. Draft answers below — refine the voice to match how you actually talk. - ---- - -## 1. What does your company do? - -Driftlock makes APIs self-maintaining. When a vendor ships a breaking change or a new feature, Driftlock scans your codebase, identifies affected usages, and opens a PR with the fix — automatically. - -API providers shouldn't just announce changes. They should apply them. - -## 2. What is the problem you are solving? - -API vendors ship breaking changes with little warning. Useful features launch quietly. Changelogs don't get read. The cost lands entirely on the consumer — production outages, silent bugs, hours of debugging. - -I worked at AWS. Over 30% of our service downtime was traced to unnoticed external API and package changes. Since then I've worked with 50+ API vendors, mostly early-stage startups. The pattern is identical everywhere: communication is broken, and the consumer eats the cost. - -This made sense before agentic coding tools existed. Now it doesn't. - -## 3. Who are your users? - -Engineering teams at small-to-mid companies who depend on external APIs (Stripe, Twilio, Shopify, etc.) and have been burned by unnoticed changes. Specifically: the person on the team who gets paged at 2am because Stripe changed a response field and nobody noticed. - -For our MVP, we're targeting TypeScript/Node teams with existing Stripe test-mode integrations. Stripe because their test mode is mature, their installed base is massive, and plenty of teams already have sandbox-hit testing. - -## 4. How do you know people want this? - -The problem is well-documented: Dependabot and Renovate proved that automated PR-based maintenance bots work at scale. CodeRabbit proved developers will give a bot PR-write access if it's useful. Agentic coding tools (Claude Code, Devin, Greptile) have normalized codebase access for external tools entirely. - -The gap is that these tools handle dependency versions and code review, but nobody handles the layer above: the actual API contract between your code and a vendor's live service. That's the unsolved problem. - -I've had 50+ conversations with API vendors and their consumers. Every consumer has a story about downtime caused by an unnoticed API change. Every vendor knows their changelogs don't get read. The pain is real and acknowledged on both sides. - -## 5. What is your unfair advantage? - -Two things: - -First, I've been on both sides. I worked at AWS where I saw the provider side of API communication failures, and I've worked with 50+ API vendors as an integrator where I experienced the consumer side. I know the problem from both angles. - -Second, the core technical insight: we don't need vendors to publish specs or cooperate at all. We infer the API contract from the customer's own code and sandbox test runs, then diff that inferred contract over time. This means we can start working with any vendor, immediately, with zero coordination. It also means we never hit the "vendor won't publish a spec" wall that kills most API monitoring tools. - -## 6. Why now? - -Three things changed in the last two years: - -1. Agentic coding tools normalized giving external tools write-adjacent access to codebases. Two years ago, "let a bot open PRs in your repo" was unthinkable. Now it's standard (Dependabot, Renovate, CodeRabbit all do this). -2. The number of API dependencies per codebase has exploded. Teams integrate with 10-20 external services. Each one is a potential drift surface. -3. AI coding tools are generating more code that calls external APIs. More generated code = more integration surface = more drift risk. - -The infrastructure for automated code changes exists. The trust curve has been crossed. What's missing is the application layer connecting API drift to customer codebases. - -## 7. Where do you see the company in 5 years? - -Driftlock becomes the default layer between API providers and their consumers — the contract enforcement layer that ensures when a vendor changes something, affected consumers know immediately and have a fix ready. - -We start with Stripe, prove the loop, then expand to every major API vendor. Over time, we build the data moat: which vendors drift most, which changes actually break things, which patterns cause the most downtime. That data becomes the industry's reference for API stability. - -Long-term, we're the reason "API broke and nobody noticed" stops being a category of production incident. - -## 8. How will you make money? - -Per-repo pricing, monthly. Think Dependabot's model — flat rate per repo, tiered by number of tracked API integrations. - -- **Free tier:** 1 repo, 1 vendor tracked (Stripe). Gets people in the door. -- **Pro:** $29/mo per repo, unlimited vendors. For small teams. -- **Team:** $99/mo per repo, priority support, coverage reports, audit logs. For companies with compliance needs. - -API vendors are not the customer. The consumer is. This avoids the enterprise BD sales cycle and lets us sell through PLG — install the GitHub App, see your coverage report, upgrade for full monitoring. - -## 9. Tell us about a time you did something impressive. - -[Fill in with your actual story — AWS experience, the 50-vendor work, or another strong example. This is where your personal credibility lands.] - -## 10. Anything else we should know? - -The hardest technical problem we've solved in our thinking: distinguishing mocked tests from sandbox-hitting tests. Most teams have test suites that look comprehensive but actually mock the API layer. Those tests are invisible to us — they produce no real signal. Our approach: we trace whether a test actually makes a network call, classify tests as "real" or "mocked," and report coverage honestly. This means we tell customers "these 3 call sites are monitored, these 7 are not" instead of pretending everything is covered. - -We're also upfront about the coverage ceiling: if you don't test it against a real sandbox, we can't catch drift on it. No tool can. We'd rather be honest about our limits than ship false confidence. - ---- - -## Notes for Nalin - -- **Question 9** needs your real story. The AWS stat is strong but it's an observation, not a "time you did something impressive." What's the thing you built, shipped, or fixed that proves you can execute? -- **Tone:** The draft is direct and specific. YC partners hate fluff. Keep it that way. -- **Length:** These are short answers. Don't expand them. Each one should be scannable in 30 seconds. -- **Review before submitting:** Read each answer out loud. If it sounds like a pitch deck, rewrite it to sound like how you'd explain it to a smart friend over coffee. diff --git a/package.json b/package.json index f652880..5096c3a 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,8 @@ }, "devDependencies": { "@types/inquirer": "^9.0.10", - "@typescript-eslint/eslint-plugin": "^6.19.0", - "@typescript-eslint/parser": "^6.19.0", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", "c8": "^9.1.0", "eslint": "^8.56.0", "eslint-config-prettier": "^9.1.0", diff --git a/packages/db/migrations/0001_grey_ender_wiggin.sql b/packages/db/migrations/0001_grey_ender_wiggin.sql new file mode 100644 index 0000000..3b27897 --- /dev/null +++ b/packages/db/migrations/0001_grey_ender_wiggin.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS "installations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "installation_id" integer NOT NULL, + "account_login" varchar(255) NOT NULL, + "account_type" varchar(50) NOT NULL, + "app_id" integer NOT NULL, + "target_selection" varchar(50) DEFAULT 'selected' NOT NULL, + "permissions" jsonb DEFAULT '{}'::jsonb NOT NULL, + "events" text[] DEFAULT '{}' NOT NULL, + "active" varchar(20) DEFAULT 'active' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "installations_installation_id_unique" UNIQUE("installation_id") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "repositories" ADD CONSTRAINT "repositories_installation_id_installations_installation_id_fk" FOREIGN KEY ("installation_id") REFERENCES "public"."installations"("installation_id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/packages/db/migrations/meta/0001_snapshot.json b/packages/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..a3c24c6 --- /dev/null +++ b/packages/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,566 @@ +{ + "id": "af3b0f72-a728-4111-b9ec-d7f814f9cbe1", + "prevId": "39edfdce-4347-4cd9-82a7-f412a9889678", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.call_sites": { + "name": "call_sites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "line": { + "name": "line", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "varchar(510)", + "primaryKey": false, + "notNull": true + }, + "http_method": { + "name": "http_method", + "type": "http_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "request_shape": { + "name": "request_shape", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "response_fields": { + "name": "response_fields", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "test_files": { + "name": "test_files", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "last_checked_at": { + "name": "last_checked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "call_sites_repository_id_repositories_id_fk": { + "name": "call_sites_repository_id_repositories_id_fk", + "tableFrom": "call_sites", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.drift_events": { + "name": "drift_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "call_site_id": { + "name": "call_site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "detected_at": { + "name": "detected_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "old_snapshot_id": { + "name": "old_snapshot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "new_snapshot_id": { + "name": "new_snapshot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "diff_summary": { + "name": "diff_summary", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "suggested_fix": { + "name": "suggested_fix", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "confidence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "drift_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'detected'" + } + }, + "indexes": {}, + "foreignKeys": { + "drift_events_call_site_id_call_sites_id_fk": { + "name": "drift_events_call_site_id_call_sites_id_fk", + "tableFrom": "drift_events", + "tableTo": "call_sites", + "columnsFrom": [ + "call_site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "drift_events_old_snapshot_id_snapshots_id_fk": { + "name": "drift_events_old_snapshot_id_snapshots_id_fk", + "tableFrom": "drift_events", + "tableTo": "snapshots", + "columnsFrom": [ + "old_snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "drift_events_new_snapshot_id_snapshots_id_fk": { + "name": "drift_events_new_snapshot_id_snapshots_id_fk", + "tableFrom": "drift_events", + "tableTo": "snapshots", + "columnsFrom": [ + "new_snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.installations": { + "name": "installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_id": { + "name": "installation_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_selection": { + "name": "target_selection", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'selected'" + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "events": { + "name": "events", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "active": { + "name": "active", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "installations_installation_id_unique": { + "name": "installations_installation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "installation_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repositories": { + "name": "repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "varchar(510)", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "language": { + "name": "language", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "last_analyzed_at": { + "name": "last_analyzed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "repositories_installation_id_installations_installation_id_fk": { + "name": "repositories_installation_id_installations_installation_id_fk", + "tableFrom": "repositories", + "tableTo": "installations", + "columnsFrom": [ + "installation_id" + ], + "columnsTo": [ + "installation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.snapshots": { + "name": "snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "call_site_id": { + "name": "call_site_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "request_shape": { + "name": "request_shape", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "response_shape": { + "name": "response_shape", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "test_command": { + "name": "test_command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "traffic_captured": { + "name": "traffic_captured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "snapshots_call_site_id_call_sites_id_fk": { + "name": "snapshots_call_site_id_call_sites_id_fk", + "tableFrom": "snapshots", + "tableTo": "call_sites", + "columnsFrom": [ + "call_site_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.confidence": { + "name": "confidence", + "schema": "public", + "values": [ + "high", + "medium", + "low" + ] + }, + "public.drift_status": { + "name": "drift_status", + "schema": "public", + "values": [ + "detected", + "fix_generated", + "pr_opened", + "merged", + "closed", + "false_positive" + ] + }, + "public.fix_type": { + "name": "fix_type", + "schema": "public", + "values": [ + "field_rename", + "type_coercion", + "null_check", + "default_value", + "custom" + ] + }, + "public.http_method": { + "name": "http_method", + "schema": "public", + "values": [ + "GET", + "POST", + "PUT", + "DELETE", + "PATCH" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 1bb4171..c08d3a8 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1789467857613, "tag": "0000_salty_rumiko_fujikawa", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1789485846567, + "tag": "0001_grey_ender_wiggin", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/package.json b/packages/db/package.json index d24acf1..1cf2a3c 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -15,12 +15,12 @@ }, "dependencies": { "@driftlock/core": "workspace:*", - "drizzle-orm": "^0.36.0", + "drizzle-orm": "^0.45.2", "postgres": "^3.4.0" }, "devDependencies": { "@types/node": "^20.11.0", - "drizzle-kit": "^0.28.0", + "drizzle-kit": "^0.31.10", "typescript": "^5.3.0" } } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index c9cbe48..4093383 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -36,12 +36,28 @@ export const fixTypeEnum = pgEnum("fix_type", [ "custom", ]); +export const installations = pgTable("installations", { + id: uuid("id").primaryKey().defaultRandom(), + installationId: integer("installation_id").notNull().unique(), + accountLogin: varchar("account_login", { length: 255 }).notNull(), + accountType: varchar("account_type", { length: 50 }).notNull(), + appId: integer("app_id").notNull(), + targetSelection: varchar("target_selection", { length: 50 }).notNull().default("selected"), + permissions: jsonb("permissions").notNull().default({}), + events: text("events").array().notNull().default([]), + active: varchar("active", { length: 20 }).notNull().default("active"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), +}); + export const repositories = pgTable("repositories", { id: uuid("id").primaryKey().defaultRandom(), owner: varchar("owner", { length: 255 }).notNull(), name: varchar("name", { length: 255 }).notNull(), fullName: varchar("full_name", { length: 510 }).notNull(), - installationId: integer("installation_id").notNull(), + installationId: integer("installation_id") + .notNull() + .references(() => installations.installationId), defaultBranch: varchar("default_branch", { length: 255 }).notNull().default("main"), language: text("language").array().notNull().default([]), lastAnalyzedAt: timestamp("last_analyzed_at"), diff --git a/packages/diff/index.ts b/packages/diff/index.ts new file mode 100644 index 0000000..d178319 --- /dev/null +++ b/packages/diff/index.ts @@ -0,0 +1,599 @@ +export type ShapeKind = + | "object" + | "array" + | "string" + | "number" + | "boolean" + | "null" + | "unknown"; + +export interface ShapeNode { + kind: ShapeKind; + nullable?: boolean; + properties?: Record; + items?: ShapeNode; + sampleCount?: number; +} + +export type Shape = Record; + +export interface TypeChange { + field: string; + oldType: string; + newType: string; +} + +export interface OptionalityChange { + field: string; + wasRequired: boolean; + nowRequired: boolean; +} + +export interface SemanticChange { + kind: + | "field_removed" + | "field_added" + | "type_changed" + | "became_nullable" + | "became_non_null" + | "request_removed" + | "request_added" + | "request_renamed"; + field: string; + from?: string; + to?: string; + oldType?: string; + newType?: string; + breaking: boolean; +} + +export interface ShapeDiffOptions { + direction?: "request" | "response"; + ignore?: string[]; +} + +export interface ShapeDiffResult { + addedFields: string[]; + removedFields: string[]; + typeChanges: TypeChange[]; + optionalityChanges: OptionalityChange[]; + breakingChanges: string[]; + nonBreakingChanges: string[]; + confidence: "high" | "medium" | "low"; + changes: SemanticChange[]; +} + +export type FixWorkKind = + | "field_rename" + | "type_coercion" + | "null_check" + | "default_value" + | "custom"; + +export interface FixWork { + kind: FixWorkKind; + field: string; + from?: string; + to?: string; + oldType?: string; + newType?: string; + description: string; + template: string; + confidence: "high" | "medium" | "low"; +} + +interface ShapeField { + path: string; + node: ShapeNode; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function tokenRegex(token: string): RegExp { + return new RegExp( + `(? 0 + ) { + count += 1; + } + return count; + } + if (node.kind === "object" && node.properties) { + let count = 0; + for (const child of Object.values(node.properties)) { + count += collectUncertainty(child); + } + return count; + } + return 0; +} + +export function inferShape(value: unknown): ShapeNode { + if (value === null) { + return { kind: "null" }; + } + if (Array.isArray(value)) { + const merged = value.reduce( + (acc, element) => { + const node = inferShape(element); + return acc === null ? node : mergeNodes(acc, node); + }, + null, + ); + return { + kind: "array", + items: merged ?? { kind: "unknown" }, + sampleCount: value.length, + }; + } + switch (typeof value) { + case "string": + return { kind: "string" }; + case "number": + return { kind: "number" }; + case "boolean": + return { kind: "boolean" }; + case "object": { + const properties: Record = {}; + for (const [key, element] of Object.entries( + value as Record, + )) { + properties[key] = inferShape(element); + } + return { kind: "object", properties }; + } + default: + return { kind: "unknown" }; + } +} + +export function mergeNodes(a: ShapeNode, b: ShapeNode): ShapeNode { + if (a.kind === "null" && b.kind === "null") { + return { kind: "null" }; + } + if (a.kind === "null" && b.kind !== "null") { + return { ...b, nullable: true }; + } + if (b.kind === "null" && a.kind !== "null") { + return { ...a, nullable: true }; + } + if (a.kind === "unknown" && b.kind !== "unknown") { + return b; + } + if (b.kind === "unknown" && a.kind !== "unknown") { + return a; + } + if (a.kind === "unknown" && b.kind === "unknown") { + return { kind: "unknown" }; + } + if (a.kind !== b.kind) { + return { ...a, nullable: true }; + } + if (a.kind === "object" && b.kind === "object") { + const properties: Record = { ...a.properties }; + for (const [key, node] of Object.entries(b.properties ?? {})) { + properties[key] = properties[key] + ? mergeNodes(properties[key], node) + : node; + } + return { + kind: "object", + properties, + nullable: a.nullable || b.nullable, + }; + } + if (a.kind === "array" && b.kind === "array") { + return { + kind: "array", + items: a.items && b.items ? mergeNodes(a.items, b.items) : a.items ?? b.items, + sampleCount: Math.max(a.sampleCount ?? 0, b.sampleCount ?? 0), + nullable: a.nullable || b.nullable, + }; + } + return { ...a, nullable: a.nullable || b.nullable }; +} + +export function flattenShape(shape: Shape, prefix = ""): ShapeField[] { + const fields: ShapeField[] = []; + for (const [key, node] of Object.entries(shape)) { + const path = prefix ? `${prefix}.${key}` : key; + fields.push({ path, node }); + fields.push(...flattenNodeChildren(node, path)); + } + return fields; +} + +function flattenNodeChildren(node: ShapeNode, path: string): ShapeField[] { + if (node.kind === "object" && node.properties) { + return flattenShape(node.properties, path); + } + if (node.kind === "array" && node.items) { + const itemsPath = `${path}[]`; + if (node.items.kind === "unknown") { + return []; + } + const fields: ShapeField[] = [{ path: itemsPath, node: node.items }]; + if (node.items.kind === "object" && node.items.properties) { + fields.push(...flattenShape(node.items.properties, itemsPath)); + } else if (node.items.kind === "array") { + fields.push(...flattenNodeChildren(node.items, itemsPath)); + } + return fields; + } + return []; +} + +function matchPath(path: string, pattern: string): boolean { + const escaped = escapeRegExp(pattern).replace(/\\\*/g, ".*"); + return new RegExp(`^${escaped}$`).test(path); +} + +export function diffShapes( + oldShape: Shape, + newShape: Shape, + options: ShapeDiffOptions = {}, +): ShapeDiffResult { + const direction = options.direction ?? "response"; + const ignore = options.ignore ?? []; + + const oldFields = flattenShape(oldShape); + const newFields = flattenShape(newShape); + const oldByPath = new Map(oldFields.map((f) => [f.path, f.node])); + const newByPath = new Map(newFields.map((f) => [f.path, f.node])); + + const allPaths = [...new Set([...oldByPath.keys(), ...newByPath.keys()])]; + const ignored = new Set(allPaths.filter((p) => ignore.some((pat) => matchPath(p, pat)))); + + const addedFields: string[] = []; + const removedFields: string[] = []; + const typeChanges: TypeChange[] = []; + const optionalityChanges: OptionalityChange[] = []; + const breakingChanges: string[] = []; + const nonBreakingChanges: string[] = []; + const changes: SemanticChange[] = []; + let uncertainty = 0; + + const removedPaths: string[] = []; + const addedPaths: string[] = []; + + for (const path of allPaths) { + if (ignored.has(path)) { + continue; + } + const oldNode = oldByPath.get(path); + const newNode = newByPath.get(path); + uncertainty += (oldNode ? collectUncertainty(oldNode) : 0); + uncertainty += (newNode ? collectUncertainty(newNode) : 0); + + if (!oldNode) { + addedPaths.push(path); + continue; + } + if (!newNode) { + removedPaths.push(path); + continue; + } + + if (oldNode.kind === newNode.kind) { + if (oldNode.nullable && !newNode.nullable) { + changes.push({ kind: "became_non_null", field: path, breaking: false }); + nonBreakingChanges.push(`Field '${path}' is no longer nullable`); + optionalityChanges.push({ + field: path, + wasRequired: false, + nowRequired: true, + }); + } else if (!oldNode.nullable && newNode.nullable) { + changes.push({ + kind: "became_nullable", + field: path, + oldType: oldNode.kind, + breaking: true, + }); + breakingChanges.push(`Field '${path}' is now nullable`); + optionalityChanges.push({ + field: path, + wasRequired: true, + nowRequired: false, + }); + } + continue; + } + + if (oldNode.kind === "null" || newNode.kind === "null") { + if (oldNode.kind === "null" && newNode.kind === "null") { + continue; + } + if (oldNode.kind === "null") { + changes.push({ kind: "became_non_null", field: path, breaking: false }); + nonBreakingChanges.push(`Field '${path}' is no longer null`); + optionalityChanges.push({ + field: path, + wasRequired: false, + nowRequired: true, + }); + continue; + } + if (!oldNode.nullable && !newNode.nullable) { + changes.push({ + kind: "became_nullable", + field: path, + oldType: oldNode.kind, + breaking: true, + }); + breakingChanges.push(`Field '${path}' is now null`); + optionalityChanges.push({ + field: path, + wasRequired: true, + nowRequired: false, + }); + } + continue; + } + + const oldKind = oldNode.kind === "unknown" ? "unknown" : oldNode.kind; + const newKind = newNode.kind === "unknown" ? "unknown" : newNode.kind; + + if (oldKind === "unknown" || newKind === "unknown") { + uncertainty += 1; + continue; + } + + changes.push({ + kind: "type_changed", + field: path, + oldType: oldKind, + newType: newKind, + breaking: true, + }); + typeChanges.push({ field: path, oldType: oldKind, newType: newKind }); + breakingChanges.push( + `Changed type of '${path}' from ${oldKind} to ${newKind}`, + ); + } + + let topLevelRemoved: string[] = []; + let topLevelAdded: string[] = []; + + if (direction === "request" && removedPaths.length === 1 && addedPaths.length === 1) { + const removedPath = removedPaths[0]; + const addedPath = addedPaths[0]; + const isTopLevel = (p: string) => + !p.includes(".") && !p.includes("["); + const removedNode = oldByPath.get(removedPath); + const addedNode = newByPath.get(addedPath); + if ( + isTopLevel(removedPath) && + isTopLevel(addedPath) && + removedNode && + addedNode && + removedNode.kind === addedNode.kind && + removedNode.kind !== "null" && + removedNode.kind !== "unknown" + ) { + changes.push({ + kind: "request_renamed", + field: removedPath, + from: removedPath, + to: addedPath, + breaking: true, + }); + breakingChanges.push( + `Renamed request parameter '${removedPath}' to '${addedPath}'`, + ); + } else { + topLevelRemoved = removedPaths; + topLevelAdded = addedPaths; + } + } else { + topLevelRemoved = removedPaths; + topLevelAdded = addedPaths; + } + + for (const path of topLevelRemoved) { + if (direction === "request") { + changes.push({ kind: "request_removed", field: path, breaking: false }); + nonBreakingChanges.push( + `Request parameter '${path}' is no longer required`, + ); + optionalityChanges.push({ + field: path, + wasRequired: true, + nowRequired: false, + }); + } else { + changes.push({ kind: "field_removed", field: path, breaking: true }); + removedFields.push(path); + breakingChanges.push(`Removed field '${path}'`); + } + } + + for (const path of topLevelAdded) { + if (direction === "request") { + changes.push({ kind: "request_added", field: path, breaking: true }); + addedFields.push(path); + breakingChanges.push(`New required request parameter '${path}'`); + optionalityChanges.push({ + field: path, + wasRequired: false, + nowRequired: true, + }); + } else { + changes.push({ kind: "field_added", field: path, breaking: false }); + addedFields.push(path); + nonBreakingChanges.push(`Added field '${path}'`); + } + } + + const confidence: "high" | "medium" | "low" = + uncertainty === 0 ? "high" : uncertainty <= 1 ? "medium" : "low"; + + return { + addedFields, + removedFields, + typeChanges, + optionalityChanges, + breakingChanges, + nonBreakingChanges, + confidence, + changes, + }; +} + +function defaultForType(type: string | undefined): string { + if (type?.includes("string")) return '""'; + if (type?.includes("number")) return "0"; + if (type?.includes("boolean")) return "false"; + return "null"; +} + +function coercerForType(type: string): string | null { + if (type.includes("number")) return "Number"; + if (type.includes("string")) return "String"; + if (type.includes("boolean")) return "Boolean"; + return null; +} + +export function fixWorksForDiff(result: ShapeDiffResult): FixWork[] { + const works: FixWork[] = []; + for (const change of result.changes) { + switch (change.kind) { + case "became_nullable": { + if (change.field.includes("[") || change.field.includes("]")) { + break; + } + const fallback = defaultForType(change.oldType); + works.push({ + kind: "null_check", + field: change.field, + oldType: change.oldType, + description: `Add a null check for '${change.field}'`, + template: `replace '${change.field}' with '${change.field} ?? ${fallback}'`, + confidence: result.confidence, + }); + break; + } + case "type_changed": { + if ( + change.field.includes("[") || + change.field.includes("]") || + change.oldType === "unknown" || + change.newType === "unknown" + ) { + break; + } + works.push({ + kind: "type_coercion", + field: change.field, + oldType: change.oldType, + newType: change.newType, + description: `Convert '${change.field}' from ${change.oldType} to ${change.newType}`, + template: `wrap '${change.field}' in a ${change.newType} coercion`, + confidence: result.confidence, + }); + break; + } + case "request_renamed": { + works.push({ + kind: "field_rename", + field: change.field, + from: change.from, + to: change.to, + description: `Rename request parameter '${change.from}' to '${change.to}'`, + template: `rename '${change.from}' to '${change.to}'`, + confidence: result.confidence, + }); + break; + } + case "request_added": { + if (change.field.includes("[") || change.field.includes("]")) { + break; + } + works.push({ + kind: "default_value", + field: change.field, + description: `Request parameter '${change.field}' is now required`, + template: `provide a default value for '${change.field}'`, + confidence: result.confidence, + }); + break; + } + case "field_removed": { + works.push({ + kind: "custom", + field: change.field, + description: `Handle removed response field '${change.field}'`, + template: `remove access to '${change.field}'`, + confidence: result.confidence, + }); + break; + } + default: + break; + } + } + return works; +} + +export function applyFixWork(work: FixWork, source: string): string | null { + switch (work.kind) { + case "field_rename": { + if (!work.from || !work.to) { + return null; + } + if (!tokenRegex(work.from).test(source)) { + return null; + } + return source.replace(tokenRegex(work.from), work.to); + } + case "null_check": { + if (!memberRegex(work.field).test(source)) { + return null; + } + const fallback = defaultForType(work.oldType); + return source.replace(memberRegex(work.field), `$& ?? ${fallback}`); + } + case "type_coercion": { + const coercer = coercerForType(work.newType ?? ""); + if (!coercer || !memberRegex(work.field).test(source)) { + return null; + } + return source.replace( + memberRegex(work.field), + `${coercer}($&)`, + ); + } + default: + return null; + } +} \ No newline at end of file diff --git a/packages/diff/package.json b/packages/diff/package.json new file mode 100644 index 0000000..5e1ea2e --- /dev/null +++ b/packages/diff/package.json @@ -0,0 +1,20 @@ +{ + "name": "@driftlock/diff", + "version": "0.1.0", + "private": true, + "main": "./index.ts", + "types": "./index.ts", + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "eslint . --ext .ts", + "test": "bun test" + }, + "dependencies": { + "@driftlock/core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "typescript": "^5.3.0" + } +} \ No newline at end of file diff --git a/packages/diff/tsconfig.json b/packages/diff/tsconfig.json new file mode 100644 index 0000000..6c2c4ab --- /dev/null +++ b/packages/diff/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "." + }, + "include": ["*.ts"] +} \ No newline at end of file diff --git a/packages/git/index.ts b/packages/git/index.ts index 7eead3e..de6648c 100644 --- a/packages/git/index.ts +++ b/packages/git/index.ts @@ -1,6 +1,15 @@ import simpleGit, { SimpleGit, StatusResult } from "simple-git"; import { CallSite } from "@driftlock/core"; +export { PRGenerator } from "./prGenerator"; +export type { PRResult, PRMetadata } from "./prGenerator"; +export { PRWriter } from "./prWriter"; +export type { + WriteFile, + WriteFixPRInput, + WordlessPRResult, +} from "./prWriter"; + export interface ChangeDetection { added: string[]; modified: string[]; @@ -33,25 +42,42 @@ export class GitTracker { async detectChanges(baseBranch?: string): Promise { if (baseBranch !== undefined) { - if (!baseBranch || baseBranch.startsWith("-") || baseBranch.includes("\0")) { + if ( + !baseBranch || + baseBranch.startsWith("-") || + baseBranch.includes("\0") + ) { throw new Error("Invalid base ref"); } - const commit = (await this.git.raw([ - "rev-parse", "--verify", "--end-of-options", `${baseBranch}^{commit}`, - ])).trim(); + const commit = ( + await this.git.raw([ + "rev-parse", + "--verify", + "--end-of-options", + `${baseBranch}^{commit}`, + ]) + ).trim(); if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(commit)) { throw new Error("Invalid base commit"); } // Compare the tracked working tree (including staged changes) to the base. const diff = await this.git.raw([ - "diff", "--name-status", "-z", "--find-renames", commit, "--", + "diff", + "--name-status", + "-z", + "--find-renames", + commit, + "--", ]); const changes: ChangeDetection = { - added: [], modified: [], deleted: [], renamed: [], + added: [], + modified: [], + deleted: [], + renamed: [], }; const fields = diff.split("\0"); - for (let i = 0; i < fields.length - 1;) { + for (let i = 0; i < fields.length - 1; ) { const status = fields[i++]; const path = fields[i++]; switch (status[0]) { diff --git a/packages/git/package.json b/packages/git/package.json index d83c3bf..6a707d5 100644 --- a/packages/git/package.json +++ b/packages/git/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@driftlock/core": "workspace:*", + "octokit": "^4.1.0", "simple-git": "^3.22.0" }, "devDependencies": { diff --git a/packages/git/prGenerator.ts b/packages/git/prGenerator.ts new file mode 100644 index 0000000..4f0f569 --- /dev/null +++ b/packages/git/prGenerator.ts @@ -0,0 +1,175 @@ +import { Octokit } from "octokit"; +import { DriftEvent, CallSite, Fix } from "@driftlock/core"; + +export interface PRResult { + url: string; + number: number; + branch: string; +} + +export interface PRMetadata { + driftEvent: DriftEvent; + callSite: CallSite; + fix: Fix; + files: Array<{ path: string; changes: string }>; +} + +export class PRGenerator { + private octokit: Octokit; + + constructor(githubToken: string, octokit?: Octokit) { + this.octokit = octokit ?? new Octokit({ auth: githubToken }); + } + + async createFixPR( + owner: string, + repo: string, + metadata: PRMetadata, + baseBranch: string = "main" + ): Promise { + const branchName = `driftlock/fix-${metadata.callSite.id.slice(0, 8)}`; + + const title = this.generateTitle(metadata); + const body = this.generateBody(metadata); + + // Create branch + const { data: baseRef } = await this.octokit.rest.git.getRef({ + owner, + repo, + ref: `heads/${baseBranch}`, + }); + + await this.octokit.rest.git.createRef({ + owner, + repo, + ref: `refs/heads/${branchName}`, + sha: baseRef.object.sha, + }); + + // Apply file changes + for (const file of metadata.files) { + await this.updateFile( + owner, + repo, + branchName, + file.path, + file.changes + ); + } + + // Create PR + const { data: pr } = await this.octokit.rest.pulls.create({ + owner, + repo, + title, + body, + head: branchName, + base: baseBranch, + }); + + return { + url: pr.html_url, + number: pr.number, + branch: branchName, + }; + } + + private generateTitle(metadata: PRMetadata): string { + const { fix, callSite } = metadata; + const action = + fix.type === "field_rename" + ? "Rename" + : fix.type === "type_coercion" + ? "Update type for" + : "Fix"; + return `driftlock: ${action} ${callSite.method} in ${callSite.filePath}`; + } + + private generateBody(metadata: PRMetadata): string { + const { driftEvent, callSite, fix } = metadata; + const confidenceEmoji = + driftEvent.confidence === "high" + ? "🟢" + : driftEvent.confidence === "medium" + ? "🟡" + : "🔴"; + + return `## DriftLock Fix + +### What changed +${fix.description} + +### Affected call site +- **File:** \`${callSite.filePath}:${callSite.line}\` +- **Method:** \`${callSite.method}\` +- **Endpoint:** \`${callSite.endpoint}\` +- **HTTP Method:** ${callSite.httpMethod} + +### Confidence +${confidenceEmoji} ${driftEvent.confidence.toUpperCase()} + +### Diff +\`\`\`diff +${fix.diff} +\`\`\` + +### Files changed +${metadata.files.map((f) => `- \`${f.path}\``).join("\n")} + +--- +*Generated by [DriftLock](https://github.com/nerdev-co/DriftLock). Self-maintaining APIs.*`; + } + + private async updateFile( + owner: string, + repo: string, + branch: string, + path: string, + content: string + ): Promise { + // Try to get existing file + let sha: string | undefined; + try { + const { data } = await this.octokit.rest.repos.getContent({ + owner, + repo, + path, + ref: branch, + }); + if ("sha" in data) { + sha = data.sha; + } + } catch { + // File doesn't exist yet + } + + const params: any = { + owner, + repo, + path, + message: `driftlock: update ${path}`, + content: Buffer.from(content).toString("base64"), + branch, + }; + + if (sha) { + params.sha = sha; + } + + await this.octokit.rest.repos.createOrUpdateFileContents(params); + } + + async addPRComment( + owner: string, + repo: string, + prNumber: number, + body: string + ): Promise { + await this.octokit.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); + } +} diff --git a/packages/git/prWriter.ts b/packages/git/prWriter.ts new file mode 100644 index 0000000..7a4109c --- /dev/null +++ b/packages/git/prWriter.ts @@ -0,0 +1,152 @@ +import { Octokit } from "octokit"; + +export interface WriteFile { + path: string; + content: string; +} + +export interface WriteFixPRInput { + owner: string; + repo: string; + base: string; + branch: string; + title: string; + body: string; + commitMessage: string; + files: WriteFile[]; + octokit: Octokit; +} + +export interface WordlessPRResult { + url: string; + number: number; + branch: string; + commitSha: string; +} + +const FILE_MODE = "100644"; + +export class PRWriter { + private octokit: Octokit; + + constructor(octokit: Octokit) { + this.octokit = octokit; + } + + /** + * Create a fix PR without a local checkout: tarball-style input becomes a + * single wordless commit via the Git Database API (blobs -> tree -> commit + * -> ref -> PR). Unchanged files keep their blob SHAs via base_tree, so the + * commit is a pure delta on top of the base branch. + */ + async writeFixPR(input: WriteFixPRInput): Promise { + const { owner, repo, base, branch, files } = input; + + if (files.length === 0) { + throw new Error("Cannot open a fix PR with no file changes"); + } + + const baseCommit = await this.resolveHead(owner, repo, base); + const blobs = await Promise.all( + files.map(async (file) => { + const { data } = await this.octokit.rest.git.createBlob({ + owner, + repo, + content: file.content, + encoding: "utf-8", + }); + return { file, sha: data.sha }; + }), + ); + + const { data: tree } = await this.octokit.rest.git.createTree({ + owner, + repo, + base_tree: baseCommit.treeSha, + tree: blobs.map(({ file, sha }) => ({ + path: file.path, + mode: FILE_MODE, + type: "blob", + sha, + })), + }); + + const { data: commit } = await this.octokit.rest.git.createCommit({ + owner, + repo, + message: input.commitMessage, + tree: tree.sha, + parents: [baseCommit.commitSha], + }); + + await this.setBranchRef(owner, repo, branch, commit.sha); + + const { data: pr } = await this.octokit.rest.pulls.create({ + owner, + repo, + title: input.title, + body: input.body, + head: branch, + base, + }); + + return { + url: pr.html_url, + number: pr.number, + branch, + commitSha: commit.sha, + }; + } + + private async resolveHead( + owner: string, + repo: string, + base: string, + ): Promise<{ commitSha: string; treeSha: string }> { + const { data: ref } = await this.octokit.rest.git.getRef({ + owner, + repo, + ref: `heads/${base}`, + }); + const { data: commit } = await this.octokit.rest.git.getCommit({ + owner, + repo, + commit_sha: ref.object.sha, + }); + return { commitSha: commit.sha, treeSha: commit.tree.sha }; + } + + /** + * Create the branch if missing; otherwise fast-forward it to the new + * commit so re-running the same migration re-generates the branch in + * place (force-push semantics for the bot branch). + */ + private async setBranchRef( + owner: string, + repo: string, + branch: string, + sha: string, + ): Promise { + try { + await this.octokit.rest.git.getRef({ + owner, + repo, + ref: `heads/${branch}`, + }); + await this.octokit.rest.git.updateRef({ + owner, + repo, + ref: `heads/${branch}`, + sha, + force: true, + }); + } catch { + await this.octokit.rest.git.createRef({ + owner, + repo, + ref: `refs/heads/${branch}`, + sha, + }); + } + } +} \ No newline at end of file diff --git a/packages/parser/package.json b/packages/parser/package.json index 13de6c8..a7799ac 100644 --- a/packages/parser/package.json +++ b/packages/parser/package.json @@ -7,7 +7,8 @@ "type": "module", "scripts": { "typecheck": "tsc --noEmit", - "lint": "eslint . --ext .ts" + "lint": "eslint . --ext .ts", + "test": "bun test" }, "dependencies": { "@driftlock/core": "workspace:*", diff --git a/packages/sandbox/proxy.ts b/packages/sandbox/proxy.ts index d2c70c1..686f36d 100644 --- a/packages/sandbox/proxy.ts +++ b/packages/sandbox/proxy.ts @@ -25,7 +25,10 @@ export class ProxyServer { const url = req.url || ""; const method = req.method || "GET"; - // Capture the request + // Capture the request body while forwarding + const requestChunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => requestChunks.push(chunk)); + const capture: TrafficCapture = { timestamp: new Date(), method, @@ -43,7 +46,7 @@ export class ProxyServer { port: targetUrl.port || (isHttps ? 443 : 80), path: targetUrl.pathname + targetUrl.search, method, - headers: req.headers, + headers: { ...req.headers, connection: "close" }, }; const proxyReq = client.request(options, (proxyRes) => { @@ -55,6 +58,19 @@ export class ProxyServer { this.captures.push(capture); + // Capture the response body while forwarding + const responseChunks: Buffer[] = []; + proxyRes.on("data", (chunk: Buffer) => responseChunks.push(chunk)); + proxyRes.on("end", () => { + const raw = Buffer.concat(responseChunks).toString("utf8"); + if (raw && capture.response) { + capture.response.body = this.parsePayload( + raw, + proxyRes.headers["content-type"], + ); + } + }); + // Forward the response res.writeHead(proxyRes.statusCode || 500, proxyRes.headers); proxyRes.pipe(res); @@ -66,13 +82,39 @@ export class ProxyServer { res.end("Bad Gateway"); }); + req.on("end", () => { + const raw = Buffer.concat(requestChunks).toString("utf8"); + if (raw) { + capture.body = this.parsePayload(raw, req.headers["content-type"]); + } + }); + // Forward request body req.pipe(proxyReq); } + private parsePayload(raw: string, contentType?: string | string[]): unknown { + const type = Array.isArray(contentType) + ? contentType.join(",") + : (contentType ?? ""); + const trimmed = raw.trimStart(); + if (/json/i.test(type) || trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + return JSON.parse(raw); + } catch { + // Not valid JSON, fall through + } + } + return raw.length > 1024 * 1024 ? raw.slice(0, 1024 * 1024) : raw; + } + start(): Promise { return new Promise((resolve) => { this.server.listen(this.port, () => { + const address = this.server.address(); + if (address && typeof address === "object") { + this.port = address.port; + } console.log(`Proxy server listening on port ${this.port}`); resolve(); }); diff --git a/packages/sandbox/runner.ts b/packages/sandbox/runner.ts index 6d66de6..d75127d 100644 --- a/packages/sandbox/runner.ts +++ b/packages/sandbox/runner.ts @@ -1,4 +1,5 @@ import Docker from "dockerode"; +import { ProxyServer } from "./proxy"; export interface SandboxConfig { image: string; @@ -45,24 +46,42 @@ export class SandboxRunner { ): Promise { const startTime = Date.now(); let container: Docker.Container | null = null; + let proxy: ProxyServer | null = null; try { // Build or pull the sandbox image await this.ensureImage(config.image); + // Start the traffic capture proxy when networking is enabled + if (config.networkEnabled) { + proxy = new ProxyServer(0); + await proxy.start(); + } + + const env = Object.entries(config.env).map( + ([key, value]) => `${key}=${value}`, + ); + if (proxy) { + const proxyUrl = `http://host.docker.internal:${proxy.getPort()}`; + env.push(`HTTP_PROXY=${proxyUrl}`); + env.push(`HTTPS_PROXY=${proxyUrl}`); + env.push("NO_PROXY=localhost,127.0.0.1"); + } + // Create container container = await this.docker.createContainer({ Image: config.image, Cmd: config.command, WorkingDir: "/workspace", - Env: Object.entries(config.env).map( - ([key, value]) => `${key}=${value}`, - ), + Env: env, HostConfig: { Binds: [`${repoPath}:/workspace:ro`], Memory: this.parseMemoryLimit(config.memoryLimit), NanoCpus: config.cpuLimit * 1e9, NetworkMode: config.networkEnabled ? "bridge" : "none", + ExtraHosts: config.networkEnabled + ? ["host.docker.internal:host-gateway"] + : undefined, }, }); @@ -82,7 +101,7 @@ export class SandboxRunner { stdout: result.stdout, stderr: result.stderr, duration, - trafficCaptured: [], // Would be populated by proxy + trafficCaptured: proxy?.getCaptures() ?? [], }; } catch (error) { const duration = Date.now() - startTime; @@ -92,9 +111,16 @@ export class SandboxRunner { stderr: error instanceof Error ? error.message : "Unknown error", duration, - trafficCaptured: [], + trafficCaptured: proxy?.getCaptures() ?? [], }; } finally { + if (proxy) { + try { + await proxy.stop(); + } catch { + // Ignore proxy cleanup errors + } + } if (container) { try { await container.remove({ force: true }); diff --git a/packages/tests/e2e/driftflow.test.ts b/packages/tests/e2e/driftflow.test.ts index a94595b..8d6a19f 100644 --- a/packages/tests/e2e/driftflow.test.ts +++ b/packages/tests/e2e/driftflow.test.ts @@ -1,92 +1,311 @@ import { describe, expect, test } from "bun:test"; -import type { - CallSite, - DriftEvent, - DiffSummary, - Fix, -} from "@driftlock/core"; - -describe("E2E: end-to-end drift detection flow", () => { - test("complete drift detection data flow", () => { - // 1. Parser extracts call sites - const callSites: CallSite[] = [ +import { TypeScriptExtractor } from "@driftlock/parser"; +import { PRGenerator } from "@driftlock/git"; +import type { DiffSummary, DriftEvent, Fix } from "@driftlock/core"; +import { + applyFixWork, + diffShapes, + fixWorksForDiff, + inferShape, + type FixWork, + type Shape, + type ShapeDiffResult, +} from "@driftlock/diff"; + +interface VendorCapture { + request: unknown; + response: unknown; +} + +interface DetectedDrift { + diff: ShapeDiffResult; + works: FixWork[]; +} + +const CALL_SITE_CODE = ` +const result = await stripe.charges.create({ + amount: 2000, + currency: "usd", + source: "tok_visa", +}); +return result.status; +`; + +function shapeOf(payload: unknown): Shape { + const node = inferShape(payload); + if (node.kind !== "object" || !node.properties) { + throw new Error("shapeOf expects an object payload"); + } + return node.properties; +} + +class InMemorySnapshotStore { + private snapshots = new Map(); + + save(callSiteId: string, request: Shape, response: Shape) { + this.snapshots.set(callSiteId, { request, response }); + } + + latest(callSiteId: string) { + return this.snapshots.get(callSiteId) ?? null; + } +} + +class FakeProbe { + constructor(private captures: VendorCapture[]) {} + + capture() { + return this.captures.shift() as VendorCapture; + } +} + +function detectDrift( + callSiteId: string, + probe: FakeProbe, + store: InMemorySnapshotStore, +): DetectedDrift | null { + const capture = probe.capture(); + const requestShape = shapeOf(capture.request); + const responseShape = shapeOf(capture.response); + + const previous = store.latest(callSiteId); + if (!previous) { + store.save(callSiteId, requestShape, responseShape); + return null; + } + + const requestDiff = diffShapes(previous.request, requestShape, { + direction: "request", + }); + const responseDiff = diffShapes(previous.response, responseShape); + + const breaking = + requestDiff.breakingChanges.concat(responseDiff.breakingChanges); + if (breaking.length === 0) { + return null; + } + + const works = [ + ...fixWorksForDiff(requestDiff), + ...fixWorksForDiff(responseDiff), + ]; + + return { diff: requestDiff, works }; +} + +function buildFix( + driftEventId: string, + works: FixWork[], + primary: FixWork, + source: string, +) { + const combined = works.reduce( + (acc, work) => applyFixWork(work, acc) ?? acc, + source, + ); + const fix: Fix = { + id: "fix_1", + driftEventId, + type: primary.kind, + description: primary.description, + diff: `- ${primary.from}: "tok_visa"\n+ ${primary.to}: "tok_visa"`, + confidence: primary.confidence, + files: [{ path: "src/payments.ts", changes: combined }], + generatedAt: new Date(), + }; + return fix; +} + +describe("E2E: drift detection pipeline", () => { + test("capture -> snapshot -> schema diff -> fix -> PR flags a real drift", async () => { + const extractor = new TypeScriptExtractor(); + const { callSites } = await extractor.extractFromFile( + "src/payments.ts", + CALL_SITE_CODE, + ); + expect(callSites).toHaveLength(1); + const callSite = callSites[0]; + expect(callSite.method).toBe("stripe.charges.create"); + expect(callSite.endpoint).toBe("/v1/charges"); + expect(callSite.httpMethod).toBe("POST"); + + const store = new InMemorySnapshotStore(); + const probe = new FakeProbe([ { - id: "cs_1", - repositoryId: "repo_1", - filePath: "src/payments.ts", - line: 10, - method: "stripe.charges.create", - endpoint: "/v1/charges", - httpMethod: "POST", - requestShape: { amount: "number", currency: "string" }, - responseFields: ["id", "status"], - testFiles: ["tests/payments.test.ts"], - lastCheckedAt: new Date("2024-01-01"), - createdAt: new Date("2024-01-01"), - updatedAt: new Date("2024-01-01"), + request: { amount: 2000, currency: "usd", source: "tok_visa" }, + response: { + id: "ch_1", + status: "succeeded", + balance_transaction: "txn_1", + }, + }, + { + request: { amount: 2000, currency: "usd", payment_method: "pm_1" }, + response: { + id: "ch_2", + status: null, + balance_transaction: "txn_1", + fee: 30, + }, }, - ]; + ]); - // 2. Git detects changes - const modifiedFiles = ["src/payments.ts"]; - const affectedCallSites = callSites.filter((cs) => - modifiedFiles.includes(cs.filePath), - ); + const first = detectDrift(callSite.id, probe, store); + expect(first).toBeNull(); + + const drift = detectDrift(callSite.id, probe, store); + expect(drift).not.toBeNull(); + const detected = drift as DetectedDrift; + + expect(detected.works).toHaveLength(2); + const kinds = detected.works.map((w) => w.kind).sort(); + expect(kinds).toEqual(["field_rename", "null_check"]); - expect(affectedCallSites).toHaveLength(1); - expect(affectedCallSites[0].method).toBe("stripe.charges.create"); + const rename = detected.works.find((w) => w.kind === "field_rename"); + expect(rename?.from).toBe("source"); + expect(rename?.to).toBe("payment_method"); - // 3. Agent analyzes drift const diffSummary: DiffSummary = { - addedFields: [], - removedFields: ["legacy_id"], - typeChanges: [ - { field: "amount", oldType: "string", newType: "number" }, - ], - optionalityChanges: [], - breakingChanges: ["Removed field 'legacy_id'"], - nonBreakingChanges: ["Changed type of 'amount' from string to number"], + addedFields: detected.diff.addedFields, + removedFields: detected.diff.removedFields, + typeChanges: detected.diff.typeChanges, + optionalityChanges: detected.diff.optionalityChanges, + breakingChanges: detected.diff.breakingChanges, + nonBreakingChanges: detected.diff.nonBreakingChanges, }; + expect(diffSummary.breakingChanges).toContain( + "Renamed request parameter 'source' to 'payment_method'", + ); - const driftEvent: DriftEvent = { + const event: DriftEvent = { id: "de_1", - callSiteId: affectedCallSites[0].id, + callSiteId: callSite.id, detectedAt: new Date(), - oldSnapshotId: "snap_old", - newSnapshotId: "snap_new", + oldSnapshotId: "snap_1", + newSnapshotId: "snap_2", diffSummary, suggestedFix: null, - confidence: "high", + confidence: detected.diff.confidence, prNumber: null, status: "detected", }; + expect(event.confidence).toBe("high"); + expect(event.status).toBe("detected"); + + const fix = buildFix( + event.id, + detected.works, + detected.works[0], + CALL_SITE_CODE, + ); + event.suggestedFix = fix; + event.status = "fix_generated"; + expect(fix.type).toBe("field_rename"); + + const changes = fix.files[0].changes; + expect(changes).toContain("payment_method: \"tok_visa\""); + expect(changes).not.toContain("source: \"tok_visa\""); + expect(changes).toContain('result.status ?? ""'); - expect(driftEvent.diffSummary.breakingChanges).toHaveLength(1); - expect(driftEvent.confidence).toBe("high"); - - // 4. Fix is generated - const fix: Fix = { - id: "fix_1", - driftEventId: driftEvent.id, - type: "field_rename", - description: "Remove legacy_id field reference", - diff: "- const id = response.legacy_id;\n+ const id = response.id;", - confidence: "high", - files: [ - { - path: "src/payments.ts", - changes: "- const id = response.legacy_id;\n+ const id = response.id;", + let pullRequestsCreated = 0; + let prTitle = ""; + const octokitStub = { + rest: { + git: { + getRef: async () => ({ + data: { object: { sha: "abc123" } }, + }), + createRef: async () => ({ data: {} }), }, - ], - generatedAt: new Date(), - }; + repos: { + getContent: async () => { + throw new Error("file not found"); + }, + createOrUpdateFileContents: async () => ({ data: {} }), + }, + pulls: { + create: async ({ + title, + }: { + title: string; + }) => { + pullRequestsCreated += 1; + prTitle = title; + return { + data: { + html_url: "https://github.com/acme/payments/pull/987", + number: 987, + }, + }; + }, + }, + }, + } as unknown as NonNullable< + ConstructorParameters[1] + >; - driftEvent.suggestedFix = fix; - driftEvent.status = "fix_generated"; + const prGenerator = new PRGenerator("github-token", octokitStub); + const pr = await prGenerator.createFixPR( + "acme", + "payments", + { driftEvent: event, callSite, fix, files: fix.files }, + "main", + ); - expect(driftEvent.suggestedFix).not.toBeNull(); - expect(driftEvent.status).toBe("fix_generated"); - expect(fix.files).toHaveLength(1); - expect(fix.files[0].path).toBe("src/payments.ts"); + expect(pr.number).toBe(987); + expect(pr.branch).toMatch(/^driftlock\/fix-/); + expect(pullRequestsCreated).toBe(1); + expect(prTitle).toContain("Rename"); + expect(prTitle).toContain("stripe.charges.create"); }); -}); + + test("identical vendor shapes produce no drift and no PR", async () => { + const extractor = new TypeScriptExtractor(); + const { callSites } = await extractor.extractFromFile( + "src/payments.ts", + CALL_SITE_CODE, + ); + const callSite = callSites[0]; + + const store = new InMemorySnapshotStore(); + const probe = new FakeProbe([ + { + request: { amount: 2000, currency: "usd", source: "tok_visa" }, + response: { id: "ch_1", status: "succeeded" }, + }, + { + request: { amount: 2000, currency: "usd", source: "tok_visa" }, + response: { id: "ch_1", status: "succeeded" }, + }, + ]); + + const first = detectDrift(callSite.id, probe, store); + expect(first).toBeNull(); + + const second = detectDrift(callSite.id, probe, store); + expect(second).toBeNull(); + + expect(store.latest(callSite.id)).not.toBeNull(); + }); + + test("first capture with no prior snapshot is skipped, not run", async () => { + const extractor = new TypeScriptExtractor(); + const { callSites } = await extractor.extractFromFile( + "src/payments.ts", + CALL_SITE_CODE, + ); + const callSite = callSites[0]; + + const store = new InMemorySnapshotStore(); + const probe = new FakeProbe([ + { + request: { amount: 1 }, + response: { id: "ch_1" }, + }, + ]); + + const result = detectDrift(callSite.id, probe, store); + expect(result).toBeNull(); + expect(store.latest(callSite.id)).not.toBeNull(); + }); +}); \ No newline at end of file diff --git a/packages/tests/package.json b/packages/tests/package.json index 54b7efa..9b5c938 100644 --- a/packages/tests/package.json +++ b/packages/tests/package.json @@ -18,6 +18,7 @@ "dependencies": { "@driftlock/agent": "workspace:*", "@driftlock/core": "workspace:*", + "@driftlock/diff": "workspace:*", "@driftlock/git": "workspace:*", "@driftlock/parser": "workspace:*", "@driftlock/sandbox": "workspace:*" diff --git a/packages/tests/unit/cli/cli.test.ts b/packages/tests/unit/cli/cli.test.ts index 5d11c18..5628e43 100644 --- a/packages/tests/unit/cli/cli.test.ts +++ b/packages/tests/unit/cli/cli.test.ts @@ -1,35 +1,70 @@ import { describe, expect, test } from "bun:test"; +import { spawn } from "child_process"; +import * as fs from "fs"; +import { tmpdir } from "os"; +import * as path from "path"; -describe("CLI module structure", () => { +const REPO_ROOT = path.resolve(import.meta.dir, "../../../.."); +const CLI_PATH = path.join(REPO_ROOT, "apps/cli/index.ts"); + +async function runCli( + args: string[], + options?: { env?: Record }, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + return new Promise((resolve) => { + const proc = spawn("bun", ["run", CLI_PATH, ...args], { + env: { ...process.env, ...options?.env }, + stdio: ["pipe", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + + proc.stdout?.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + + proc.stderr?.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + proc.on("close", (code) => { + resolve({ + stdout, + stderr, + exitCode: code ?? 1, + }); + }); + + proc.on("error", () => { + resolve({ + stdout, + stderr, + exitCode: 1, + }); + }); + }); +} + +describe("CLI structure", () => { test("index.ts exists and is importable", async () => { - // Verify the CLI entry point can be found - const fs = await import("fs"); - const path = await import("path"); - const cliPath = path.resolve(import.meta.dir, "../../../../apps/cli/index.ts"); - expect(fs.existsSync(cliPath)).toBe(true); + expect(fs.existsSync(CLI_PATH)).toBe(true); }); test("CLI package.json has correct name", async () => { - const fs = await import("fs"); - const path = await import("path"); - const pkgPath = path.resolve(import.meta.dir, "../../../../apps/cli/package.json"); + const pkgPath = path.join(REPO_ROOT, "apps/cli/package.json"); const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")); expect(pkg.name).toBe("@driftlock/cli"); }); test("CLI package.json has build script", async () => { - const fs = await import("fs"); - const path = await import("path"); - const pkgPath = path.resolve(import.meta.dir, "../../../../apps/cli/package.json"); + const pkgPath = path.join(REPO_ROOT, "apps/cli/package.json"); const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")); expect(pkg.scripts?.build).toBeDefined(); - expect(typeof pkg.scripts.build).toBe("string"); }); test("CLI package.json has all required dependencies", async () => { - const fs = await import("fs"); - const path = await import("path"); - const pkgPath = path.resolve(import.meta.dir, "../../../../apps/cli/package.json"); + const pkgPath = path.join(REPO_ROOT, "apps/cli/package.json"); const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")); expect(pkg.dependencies).toHaveProperty("@driftlock/agent"); @@ -43,20 +78,142 @@ describe("CLI module structure", () => { }); test("CLI package.json has bin entry", async () => { - const fs = await import("fs"); - const path = await import("path"); - const pkgPath = path.resolve(import.meta.dir, "../../../../apps/cli/package.json"); + const pkgPath = path.join(REPO_ROOT, "apps/cli/package.json"); const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")); expect(pkg.bin).toBeDefined(); expect(pkg.bin.driftlock).toBe("./dist/index.js"); }); test("CLI dist/index.js is built", async () => { - const fs = await import("fs"); - const path = await import("path"); - const distPath = path.resolve(import.meta.dir, "../../../../apps/cli/dist/index.js"); + const distPath = path.join(REPO_ROOT, "apps/cli/dist/index.js"); expect(fs.existsSync(distPath)).toBe(true); const stat = fs.statSync(distPath); expect(stat.size).toBeGreaterThan(0); }); }); + +describe("CLI --help", () => { + test("shows program description and commands", async () => { + const result = await runCli(["--help"]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("driftlock"); + expect(result.stdout).toContain("Self-maintaining APIs"); + expect(result.stdout).toContain("analyze"); + expect(result.stdout).toContain("test"); + expect(result.stdout).toContain("diff"); + expect(result.stdout).toContain("fix"); + expect(result.stdout).toContain("init"); + }); + + test("shows examples in help", async () => { + const result = await runCli(["--help"]); + + expect(result.stdout).toContain("driftlock analyze"); + expect(result.stdout).toContain("driftlock fix"); + }); +}); + +describe("CLI --version", () => { + test("shows version number", async () => { + const result = await runCli(["--version"]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("0.1.0"); + }); +}); + +describe("CLI analyze command", () => { + test("analyze --help shows command description", async () => { + const result = await runCli(["analyze", "--help"]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Scan codebase"); + expect(result.stdout).toContain("API call sites"); + expect(result.stdout).toContain("--output"); + }); + + test("analyze on directory with no TS files", async () => { + const directory = fs.mkdtempSync(path.join(tmpdir(), "driftlock-cli-")); + try { + const result = await runCli(["analyze", directory, "--output", "json"]); + + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ callSites: [], errors: [] }); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); +}); + +describe("CLI test command", () => { + test("test --help shows command description", async () => { + const result = await runCli(["test", "--help"]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("sandbox"); + expect(result.stdout).toContain("--command"); + expect(result.stdout).toContain("--timeout"); + }); +}); + +describe("CLI diff command", () => { + test("diff --help shows command description", async () => { + const result = await runCli(["diff", "--help"]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Compare"); + expect(result.stdout).toContain("--base"); + }); +}); + +describe("CLI fix command", () => { + test("fix --help shows command description", async () => { + const result = await runCli(["fix", "--help"]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Detect API drift"); + expect(result.stdout).toContain("--dry-run"); + expect(result.stdout).toContain("--repo"); + expect(result.stdout).toContain("--base"); + expect(result.stdout).toContain("GITHUB_TOKEN"); + }); + + test("fix --help shows the full loop explanation", async () => { + const result = await runCli(["fix", "--help"]); + + expect(result.stdout).toContain("Scans for API call sites"); + expect(result.stdout).toContain("establishes a baseline snapshot"); + expect(result.stdout).toContain("Generates deterministic fixes"); + expect(result.stdout).toContain("creates a PR"); + }); +}); + +describe("CLI init command", () => { + test("init --help shows command description", async () => { + const result = await runCli(["init", "--help"]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Initialize"); + expect(result.stdout).toContain(".driftlock.yml"); + expect(result.stdout).toContain("OPENAI_API_KEY"); + }); +}); + +describe("CLI unknown command", () => { + test("shows error for unknown command", async () => { + const result = await runCli(["nonexistent"]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("unknown command"); + }); +}); + +describe("CLI unknown option", () => { + test("shows error for unknown option", async () => { + const result = await runCli(["analyze", "--unknown"]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("unknown option"); + }); +}); diff --git a/packages/tests/unit/diff/diffShapes.test.ts b/packages/tests/unit/diff/diffShapes.test.ts new file mode 100644 index 0000000..dff7a63 --- /dev/null +++ b/packages/tests/unit/diff/diffShapes.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, test } from "bun:test"; +import { + diffShapes, + inferShape, + type Shape, + type ShapeDiffOptions, + type ShapeNode, +} from "@driftlock/diff"; + +function shapeOf(payload: unknown): Shape { + const node = inferShape(payload); + if (node.kind !== "object" || !node.properties) { + throw new Error("shapeOf expects an object payload"); + } + return node.properties; +} + +function diff( + oldPayload: unknown, + newPayload: unknown, + options: ShapeDiffOptions = {}, +) { + return diffShapes(shapeOf(oldPayload), shapeOf(newPayload), options); +} + +describe("diffShapes", () => { + const cases: Array<{ + name: string; + oldPayload: unknown; + newPayload: unknown; + options?: ShapeDiffOptions; + breaking?: string[]; + nonBreaking?: string[]; + confidence?: "high" | "medium" | "low"; + removed?: string[]; + added?: string[]; + }> = [ + { + name: "identical shapes produce no drift", + oldPayload: { id: "ch_1", amount: 100, status: "succeeded" }, + newPayload: { id: "ch_2", amount: 120, status: "succeeded" }, + breaking: [], + nonBreaking: [], + confidence: "high", + }, + { + name: "removed response field is breaking", + oldPayload: { id: "ch_1", legacy_id: "x" }, + newPayload: { id: "ch_1" }, + breaking: ["Removed field 'legacy_id'"], + removed: ["legacy_id"], + }, + { + name: "added response field is non-breaking", + oldPayload: { id: "ch_1" }, + newPayload: { id: "ch_1", fee: 30 }, + nonBreaking: ["Added field 'fee'"], + added: ["fee"], + breaking: [], + }, + { + name: "type change is breaking", + oldPayload: { amount: "100" }, + newPayload: { amount: 100 }, + breaking: ["Changed type of 'amount' from string to number"], + }, + { + name: "field becoming null triggers a null check", + oldPayload: { status: "succeeded" }, + newPayload: { status: null }, + breaking: ["Field 'status' is now null"], + }, + { + name: "nullable flag removal is non-breaking", + oldPayload: { a: "x" }, + newPayload: { a: "y" }, + options: { direction: "response" }, + breaking: [], + nonBreaking: [], + }, + { + name: "removed request parameter is non-breaking", + oldPayload: { amount: 100, source: "tok" }, + newPayload: { amount: 100 }, + options: { direction: "request" }, + nonBreaking: ["Request parameter 'source' is no longer required"], + breaking: [], + }, + { + name: "added request parameter is breaking", + oldPayload: { amount: 100 }, + newPayload: { amount: 100, payment_method: "pm_1" }, + options: { direction: "request" }, + breaking: ["New required request parameter 'payment_method'"], + added: ["payment_method"], + }, + { + name: "paired top-level request rename is detected as a rename", + oldPayload: { amount: 100, source: "tok" }, + newPayload: { amount: 100, payment_method: "pm" }, + options: { direction: "request" }, + breaking: [ + "Renamed request parameter 'source' to 'payment_method'", + ], + removed: [], + added: [], + }, + { + name: "rename requires matching kinds", + oldPayload: { amount: 100, source: "tok" }, + newPayload: { amount: 100, payment_method: 42 }, + options: { direction: "request" }, + breaking: ["New required request parameter 'payment_method'"], + nonBreaking: ["Request parameter 'source' is no longer required"], + added: ["payment_method"], + }, + { + name: "present null then absent is a removal, not a null transition", + oldPayload: { a: null }, + newPayload: {}, + breaking: ["Removed field 'a'"], + removed: ["a"], + }, + { + name: "present null both sides is no drift", + oldPayload: { a: null, b: 1 }, + newPayload: { a: null, b: 1 }, + breaking: [], + nonBreaking: [], + }, + { + name: "value changes with identical types are no drift", + oldPayload: { created_at: "2024-01-01", id: "a" }, + newPayload: { created_at: "2024-01-02", id: "b" }, + breaking: [], + nonBreaking: [], + }, + { + name: "ignored fields do not trigger drift", + oldPayload: { created_at: "2024-01-01", id: "a" }, + newPayload: { created_at: null, id: "b" }, + options: { ignore: ["created_at"] }, + breaking: [], + nonBreaking: [], + }, + { + name: "empty array to typed array lowers confidence without drift", + oldPayload: { items: [] }, + newPayload: { items: [{ id: "i1" }] }, + breaking: [], + confidence: "medium", + }, + { + name: "array element type change is breaking", + oldPayload: { items: [1, 2] }, + newPayload: { items: ["a", "b"] }, + breaking: ["Changed type of 'items[]' from number to string"], + }, + { + name: "nested object change reports the full path", + oldPayload: { customer: { billing: { address: "123 Main" } } }, + newPayload: { customer: { billing: { address: 123 } } }, + breaking: [ + "Changed type of 'customer.billing.address' from string to number", + ], + }, + { + name: "array of objects reports element field paths", + oldPayload: { data: [{ amount: "100" }] }, + newPayload: { data: [{ amount: 100 }] }, + breaking: ["Changed type of 'data[].amount' from string to number"], + }, + { + name: "multiple undersampled arrays drop confidence to low", + oldPayload: { a: [], b: [] }, + newPayload: { a: [], b: [] }, + breaking: [], + confidence: "low", + }, + ]; + + for (const c of cases) { + test(c.name, () => { + const result = diff( + c.oldPayload, + c.newPayload, + c.options ?? {}, + ); + if (c.breaking) { + for (const msg of c.breaking) { + expect(result.breakingChanges).toContain(msg); + } + } + if (c.nonBreaking) { + for (const msg of c.nonBreaking) { + expect(result.nonBreakingChanges).toContain(msg); + } + } + if (c.confidence) { + expect(result.confidence).toBe(c.confidence); + } + if (c.removed) { + for (const field of c.removed) { + expect(result.removedFields).toContain(field); + } + } + if (c.added) { + for (const field of c.added) { + expect(result.addedFields).toContain(field); + } + } + if (c.breaking?.length) { + expect( + result.breakingChanges.filter((m) => + c.breaking!.includes(m), + ), + ).toHaveLength(c.breaking.length); + } + }); + } +}); + +describe("diffShapes metadata", () => { + test("type changes carry old and new type", () => { + const result = diff({ amount: "100" }, { amount: 100 }); + expect(result.typeChanges).toEqual([ + { field: "amount", oldType: "string", newType: "number" }, + ]); + }); + + test("optionality change is recorded when a field becomes null", () => { + const result = diff({ status: "ok" }, { status: null }); + expect(result.optionalityChanges).toEqual([ + { field: "status", wasRequired: true, nowRequired: false }, + ]); + }); + + test("nullable widening between samples is breaking", () => { + const oldShape: Shape = { + status: { kind: "string" }, + }; + const newShape: Shape = { + status: { kind: "string", nullable: true }, + }; + const result = diffShapes(oldShape, newShape); + expect(result.breakingChanges).toContain("Field 'status' is now nullable"); + expect(result.changes.filter((c) => c.kind === "became_nullable")).toHaveLength(1); + }); + + test("nullable removal is non-breaking", () => { + const oldShape: Shape = { + status: { kind: "string", nullable: true }, + }; + const newShape: Shape = { + status: { kind: "string" }, + }; + const result = diffShapes(oldShape, newShape); + expect(result.breakingChanges).toHaveLength(0); + expect(result.nonBreakingChanges).toContain( + "Field 'status' is no longer nullable", + ); + }); + + test("null to concrete is a non-breaking narrowing", () => { + const result = diff({ a: null }, { a: "x" }); + expect(result.breakingChanges).toHaveLength(0); + expect(result.nonBreakingChanges).toContain("Field 'a' is no longer null"); + }); + + test("node path for nullable with null value is inferred", () => { + const node: ShapeNode = inferShape({ a: "x" }) as ShapeNode; + expect(node.kind).toBe("object"); + const field = (node as { properties: Record }) + .properties.a as ShapeNode; + expect(field.kind).toBe("string"); + expect(field.nullable).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/packages/tests/unit/diff/fixWorks.test.ts b/packages/tests/unit/diff/fixWorks.test.ts new file mode 100644 index 0000000..514ba17 --- /dev/null +++ b/packages/tests/unit/diff/fixWorks.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, test } from "bun:test"; +import { + applyFixWork, + diffShapes, + fixWorksForDiff, + inferShape, + type Shape, + type FixWork, +} from "@driftlock/diff"; + +function shapeOf(payload: unknown): Shape { + const node = inferShape(payload); + if (node.kind !== "object" || !node.properties) { + throw new Error("shapeOf expects an object payload"); + } + return node.properties; +} + +function diff( + oldPayload: unknown, + newPayload: unknown, + options: { direction?: "request" | "response" } = {}, +) { + return diffShapes(shapeOf(oldPayload), shapeOf(newPayload), options); +} + +describe("fixWorksForDiff", () => { + test("became nullable maps to a null_check fix", () => { + const result = diff({ status: "succeeded" }, { status: null }); + const works = fixWorksForDiff(result); + expect(works).toHaveLength(1); + expect(works[0].kind).toBe("null_check"); + expect(works[0].field).toBe("status"); + expect(works[0].oldType).toBe("string"); + expect(works[0].confidence).toBe("high"); + }); + + test("request rename maps to a field_rename fix", () => { + const result = diff( + { amount: 100, source: "tok" }, + { amount: 100, payment_method: "pm" }, + { direction: "request" }, + ); + const works = fixWorksForDiff(result); + expect(works).toHaveLength(1); + expect(works[0].kind).toBe("field_rename"); + expect(works[0].from).toBe("source"); + expect(works[0].to).toBe("payment_method"); + }); + + test("request rename with mismatched kinds does not rename", () => { + const result = diff( + { amount: 100, source: "tok" }, + { amount: 100, payment_method: 42 }, + { direction: "request" }, + ); + const works = fixWorksForDiff(result); + expect(works.some((w) => w.kind === "field_rename")).toBe(false); + expect(works.some((w) => w.kind === "default_value")).toBe(true); + }); + + test("new required request parameter maps to default_value", () => { + const result = diff( + { amount: 100 }, + { amount: 100, payment_method: "pm" }, + { direction: "request" }, + ); + const works = fixWorksForDiff(result); + expect(works[0].kind).toBe("default_value"); + expect(works[0].field).toBe("payment_method"); + }); + + test("type change maps to a type_coercion fix", () => { + const result = diff({ amount: "100" }, { amount: 100 }); + const works = fixWorksForDiff(result); + expect(works[0].kind).toBe("type_coercion"); + expect(works[0].oldType).toBe("string"); + expect(works[0].newType).toBe("number"); + }); + + test("removed response field maps to a custom fix", () => { + const result = diff({ id: "a", legacy_id: "x" }, { id: "a" }); + const works = fixWorksForDiff(result); + expect(works[0].kind).toBe("custom"); + expect(works[0].field).toBe("legacy_id"); + }); + + test("non-breaking changes produce no fixes", () => { + const result = diff({ id: "a" }, { id: "a", fee: 30 }); + expect(result.changes.some((c) => c.breaking)).toBe(false); + expect(fixWorksForDiff(result)).toHaveLength(0); + }); + + test("array element paths produce no templated fixes", () => { + const result = diff({ data: [{ amount: "1" }] }, { data: [{ amount: 1 }] }); + const works = fixWorksForDiff(result); + expect(works.some((w) => w.kind === "type_coercion")).toBe(false); + }); + + test("multiple breaking changes produce one fix per change", () => { + const oldShape: Shape = { + status: { kind: "string" }, + amount: { kind: "string" }, + }; + const newShape: Shape = { + status: { kind: "string", nullable: true }, + amount: { kind: "number" }, + }; + const result = diffShapes(oldShape, newShape); + const works = fixWorksForDiff(result); + expect(works.map((w) => w.kind)).toEqual(["null_check", "type_coercion"]); + }); +}); + +describe("applyFixWork", () => { + test("renames a request parameter token", () => { + const work: FixWork = { + kind: "field_rename", + field: "source", + from: "source", + to: "payment_method", + description: "Rename request parameter 'source' to 'payment_method'", + template: "rename 'source' to 'payment_method'", + confidence: "high", + }; + const source = "create({ amount: 100, source: \"tok_visa\" })"; + expect(applyFixWork(work, source)).toBe( + "create({ amount: 100, payment_method: \"tok_visa\" })", + ); + }); + + test("does not rename a token that is part of a longer word", () => { + const work: FixWork = { + kind: "field_rename", + field: "source", + from: "source", + to: "payment_method", + description: "Rename request parameter 'source' to 'payment_method'", + template: "rename 'source' to 'payment_method'", + confidence: "high", + }; + const source = "create({ source: auth.sourceId })"; + expect(applyFixWork(work, source)).toBe( + "create({ payment_method: auth.sourceId })", + ); + }); + + test("adds a null check with a string fallback", () => { + const work: FixWork = { + kind: "null_check", + field: "status", + oldType: "string", + description: "Add a null check for 'status'", + template: "replace 'status' with 'status ?? \"\"'", + confidence: "high", + }; + const source = "return charge.status;"; + expect(applyFixWork(work, source)).toBe('return charge.status ?? "";'); + }); + + test("uses a zero fallback for numbers", () => { + const work: FixWork = { + kind: "null_check", + field: "total", + oldType: "number", + description: "Add a null check for 'total'", + template: "replace 'total' with 'total ?? 0'", + confidence: "high", + }; + expect(applyFixWork(work, "return total;")).toBe("return total ?? 0;"); + }); + + test("wraps a field in a type coercion", () => { + const work: FixWork = { + kind: "type_coercion", + field: "amount", + oldType: "string", + newType: "number", + description: "Convert 'amount' from string to number", + template: "wrap 'amount' in a number coercion", + confidence: "high", + }; + const source = "total(result.amount)"; + expect(applyFixWork(work, source)).toBe("total(Number(result.amount))"); + }); + + test("returns null when the field is absent", () => { + const work: FixWork = { + kind: "field_rename", + field: "source", + from: "source", + to: "payment_method", + description: "Rename request parameter 'source' to 'payment_method'", + template: "rename 'source' to 'payment_method'", + confidence: "high", + }; + expect(applyFixWork(work, "create({ amount: 1 })")).toBeNull(); + }); + + test("returns null for non-applicable kinds", () => { + const work: FixWork = { + kind: "default_value", + field: "payment_method", + description: "Request parameter 'payment_method' is now required", + template: "provide a default value for 'payment_method'", + confidence: "high", + }; + expect(applyFixWork(work, "create({})")).toBeNull(); + + const custom: FixWork = { + kind: "custom", + field: "legacy_id", + description: "Handle removed response field 'legacy_id'", + template: "remove access to 'legacy_id'", + confidence: "high", + }; + expect(applyFixWork(custom, "const id = legacy_id;")).toBeNull(); + }); +}); \ No newline at end of file diff --git a/packages/tests/unit/diff/inferShape.test.ts b/packages/tests/unit/diff/inferShape.test.ts new file mode 100644 index 0000000..0f14a03 --- /dev/null +++ b/packages/tests/unit/diff/inferShape.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from "bun:test"; +import { + inferShape, + mergeNodes, + flattenShape, + type Shape, + type ShapeNode, +} from "@driftlock/diff"; + +function shapeOf(payload: unknown): Shape { + const node = inferShape(payload); + if (node.kind !== "object" || !node.properties) { + throw new Error("shapeOf expects an object payload"); + } + return node.properties; +} + +describe("inferShape", () => { + test("infers scalar kinds", () => { + expect(inferShape("abc").kind).toBe("string"); + expect(inferShape(42).kind).toBe("number"); + expect(inferShape(true).kind).toBe("boolean"); + expect(inferShape(null).kind).toBe("null"); + }); + + test("infers nested objects", () => { + const node = inferShape({ + id: "ch_1", + customer: { billing: { address: "123 Main St" } }, + }); + expect(node.kind).toBe("object"); + expect( + (node.properties?.customer as ShapeNode).kind, + ).toBe("object"); + const billing = (node.properties?.customer as ShapeNode).properties + ?.billing as ShapeNode; + expect(billing.kind).toBe("object"); + expect((billing.properties?.address as ShapeNode).kind).toBe("string"); + }); + + test("infers typed arrays", () => { + const node = inferShape([1, 2, 3]); + expect(node.kind).toBe("array"); + expect(node.sampleCount).toBe(3); + expect((node.items as ShapeNode).kind).toBe("number"); + }); + + test("infers empty arrays as unknown", () => { + const node = inferShape([]); + expect(node.kind).toBe("array"); + expect(node.sampleCount).toBe(0); + expect((node.items as ShapeNode).kind).toBe("unknown"); + }); + + test("infers objects", () => { + const node = inferShape({ amount: 10, ok: false }); + expect((node.properties?.amount as ShapeNode).kind).toBe("number"); + expect((node.properties?.ok as ShapeNode).kind).toBe("boolean"); + }); + + test("falls back to unknown for undefined", () => { + expect(inferShape(undefined).kind).toBe("unknown"); + }); +}); + +describe("mergeNodes", () => { + test("null plus concrete widens to nullable concrete", () => { + const merged = mergeNodes(inferShape("a"), inferShape(null)); + expect(merged.kind).toBe("string"); + expect(merged.nullable).toBe(true); + }); + + test("concrete plus null widens to nullable concrete", () => { + const merged = mergeNodes(inferShape(null), inferShape(3)); + expect(merged.kind).toBe("number"); + expect(merged.nullable).toBe(true); + }); + + test("two nulls stay null", () => { + const merged = mergeNodes(inferShape(null), inferShape(null)); + expect(merged.kind).toBe("null"); + expect(merged.nullable).toBeFalsy(); + }); + + test("unknown defers to a known kind", () => { + expect(mergeNodes(inferShape(undefined), inferShape(1)).kind).toBe( + "number", + ); + expect(mergeNodes(inferShape(1), inferShape(undefined)).kind).toBe( + "number", + ); + }); + + test("merges object properties", () => { + const a = inferShape({ x: 1 }); + const b = inferShape({ y: "v" }); + const merged = mergeNodes(a, b); + expect(merged.kind).toBe("object"); + expect((merged.properties?.x as ShapeNode).kind).toBe("number"); + expect((merged.properties?.y as ShapeNode).kind).toBe("string"); + }); + + test("merges array element shapes", () => { + const merged = mergeNodes(inferShape([1, 2]), inferShape([3])); + expect(merged.kind).toBe("array"); + expect((merged.items as ShapeNode).kind).toBe("number"); + expect(merged.sampleCount).toBe(2); + }); +}); + +describe("flattenShape", () => { + test("flattens nested object paths", () => { + const paths = flattenShape( + shapeOf({ id: "x", customer: { address: "123" } }), + ).map((f) => f.path); + expect(paths).toContain("id"); + expect(paths).toContain("customer"); + expect(paths).toContain("customer.address"); + }); + + test("flattens array element paths", () => { + const fields = flattenShape( + shapeOf({ items: [{ name: "n", qty: 2 }] }), + ); + const paths = fields.map((f) => f.path); + expect(paths).toContain("items[]"); + expect(paths).toContain("items[].name"); + expect(paths).toContain("items[].qty"); + }); +}); \ No newline at end of file diff --git a/packages/tests/unit/git/prWriter.test.ts b/packages/tests/unit/git/prWriter.test.ts new file mode 100644 index 0000000..c4a2c14 --- /dev/null +++ b/packages/tests/unit/git/prWriter.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, test } from "bun:test"; +import { PRWriter, type WriteFile } from "@driftlock/git"; + +function makeFake() { + const calls: Array<{ name: string; params: any }> = []; + let branchExists = false; + + const octokit = { + rest: { + git: { + async getRef({ ref }: any) { + calls.push({ name: "git.getRef", params: { ref } }); + if (ref === "heads/driftlock/prisma-6-7" && !branchExists) { + const err: any = new Error("Not Found"); + err.status = 404; + throw err; + } + return { data: { object: { sha: "base-commit" } } }; + }, + async getCommit() { + calls.push({ name: "git.getCommit", params: {} }); + return { + data: { sha: "base-commit", tree: { sha: "base-tree" } }, + }; + }, + async createBlob(params: any) { + calls.push({ name: "git.createBlob", params }); + return { data: { sha: `blob-${params.content.length}` } }; + }, + async createTree(params: any) { + calls.push({ name: "git.createTree", params }); + return { data: { sha: "tree-new" } }; + }, + async createCommit(params: any) { + calls.push({ name: "git.createCommit", params }); + return { data: { sha: "commit-new" } }; + }, + async createRef(params: any) { + calls.push({ name: "git.createRef", params }); + }, + async updateRef(params: any) { + calls.push({ name: "git.updateRef", params }); + }, + }, + pulls: { + async create(params: any) { + calls.push({ name: "pulls.create", params }); + return { + data: { + html_url: `https://github.com/${params.owner}/${params.repo}/pull/42`, + number: 42, + }, + }; + }, + }, + }, + }; + + const byName = (name: string) => + calls.filter((c) => c.name === name).map((c) => c.params); + + return { + octokit: octokit as any, + calls, + byName, + setBranchExists: (v: boolean) => { + branchExists = v; + }, + }; +} + +const FILES: WriteFile[] = [ + { path: "src/billing.ts", content: "export const price = amount * 2;" }, + { path: "package.json", content: '{"prisma":"7.0.0"}' }, +]; + +function makeInput() { + return { + owner: "acme", + repo: "app", + base: "main", + branch: "driftlock/prisma-6-7", + title: "driftlock: migrate prisma 6 -> 7", + body: "## What changed\nPrisma changed the client API.", + commitMessage: "driftlock: migrate prisma 6 -> 7", + files: FILES, + }; +} + +describe("PRWriter (wordless fix PR)", () => { + test("creates branch, single delta commit, and PR", async () => { + const fake = makeFake(); + const writer = new PRWriter(fake.octokit); + + const result = await writer.writeFixPR({ + ...makeInput(), + octokit: fake.octokit, + }); + + expect(result).toEqual({ + url: "https://github.com/acme/app/pull/42", + number: 42, + branch: "driftlock/prisma-6-7", + commitSha: "commit-new", + }); + + expect(fake.byName("git.getRef")[0]).toMatchObject({ + ref: "heads/main", + }); + expect(fake.byName("git.getRef")[1]).toMatchObject({ + ref: "heads/driftlock/prisma-6-7", + }); + + const blobs = fake.byName("git.createBlob"); + expect(blobs).toHaveLength(2); + expect(blobs[0]).toMatchObject({ content: FILES[0].content }); + expect(blobs[1]).toMatchObject({ content: FILES[1].content }); + + expect(fake.byName("git.createTree")[0]).toMatchObject({ + base_tree: "base-tree", + tree: [ + { + path: "src/billing.ts", + mode: "100644", + type: "blob", + sha: "blob-32", + }, + { + path: "package.json", + mode: "100644", + type: "blob", + sha: "blob-18", + }, + ], + }); + + expect(fake.byName("git.createCommit")[0]).toMatchObject({ + message: "driftlock: migrate prisma 6 -> 7", + tree: "tree-new", + parents: ["base-commit"], + }); + + expect(fake.byName("git.createRef")[0]).toMatchObject({ + ref: "refs/heads/driftlock/prisma-6-7", + sha: "commit-new", + }); + expect(fake.byName("git.updateRef")).toHaveLength(0); + + const pr = fake.byName("pulls.create")[0]; + expect(pr).toMatchObject({ + owner: "acme", + repo: "app", + title: "driftlock: migrate prisma 6 -> 7", + head: "driftlock/prisma-6-7", + base: "main", + }); + }); + + test("force-updates an existing branch instead of crashing", async () => { + const fake = makeFake(); + fake.setBranchExists(true); + const writer = new PRWriter(fake.octokit); + + await writer.writeFixPR({ ...makeInput(), octokit: fake.octokit }); + + expect(fake.byName("git.updateRef")[0]).toMatchObject({ + ref: "heads/driftlock/prisma-6-7", + sha: "commit-new", + force: true, + }); + expect(fake.byName("git.createRef")).toHaveLength(0); + }); + + test("writes blobs as utf-8", async () => { + const fake = makeFake(); + const writer = new PRWriter(fake.octokit); + + await writer.writeFixPR({ ...makeInput(), octokit: fake.octokit }); + + const blobs = fake.byName("git.createBlob"); + expect(blobs).toHaveLength(2); + for (const params of blobs) { + expect(params.encoding).toBe("utf-8"); + expect(params.owner).toBe("acme"); + expect(params.repo).toBe("app"); + } + }); + + test("rejects an empty file list", async () => { + const fake = makeFake(); + const writer = new PRWriter(fake.octokit); + + await expect( + writer.writeFixPR({ + ...makeInput(), + octokit: fake.octokit, + files: [], + }), + ).rejects.toThrow("no file changes"); + + expect(fake.byName("git.getRef")).toHaveLength(0); + }); +}); \ No newline at end of file diff --git a/packages/tests/unit/sandbox/proxy.test.ts b/packages/tests/unit/sandbox/proxy.test.ts index 3dc216a..39d3e0a 100644 --- a/packages/tests/unit/sandbox/proxy.test.ts +++ b/packages/tests/unit/sandbox/proxy.test.ts @@ -1,4 +1,6 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { createServer, request } from "http"; +import type { AddressInfo } from "net"; import { ProxyServer } from "@driftlock/sandbox/proxy"; describe("ProxyServer", () => { @@ -56,4 +58,102 @@ describe("ProxyServer", () => { const s2 = new ProxyServer(0); expect(s2.getPort()).toBe(0); }); + + test("captures request and response bodies through the proxy", async () => { + const upstream = await startTestUpstream(); + const proxy = new ProxyServer(0); + await proxy.start(); + + try { + const proxied = await proxyRequest( + proxy.getPort(), + upstream.port, + "/v1/charges", + JSON.stringify({ + amount: 2000, + currency: "usd", + source: "tok_visa", + }), + ); + expect(proxied.status).toBe(201); + + const captures = proxy.getCaptures(); + expect(captures).toHaveLength(1); + const capture = captures[0]; + expect(capture.method).toBe("POST"); + expect(capture.body).toEqual({ + amount: 2000, + currency: "usd", + source: "tok_visa", + }); + expect(capture.response?.body).toEqual({ + id: "ch_1", + status: "succeeded", + }); + expect(capture.url).toContain("/v1/charges"); + } finally { + await proxy.stop(); + await upstream.close(); + } + }); }); + +interface TestUpstream { + port: number; + close: () => Promise; +} + +function startTestUpstream(): Promise { + const server = createServer((req, res) => { + req.resume(); + req.on("end", () => { + res.writeHead(201, { + "content-type": "application/json", + connection: "close", + }); + res.end( + JSON.stringify({ + id: "ch_1", + status: "succeeded", + }), + ); + }); + }); + return new Promise((resolve) => { + server.listen(0, () => { + const { port } = server.address() as AddressInfo; + resolve({ + port, + close: () => + new Promise((done) => server.close(() => done())), + }); + }); + }); +} + +function proxyRequest( + proxyPort: number, + upstreamPort: number, + path: string, + body: string, +): Promise<{ status: number }> { + return new Promise((resolve, reject) => { + const req = request( + { + hostname: "127.0.0.1", + port: proxyPort, + method: "POST", + path: `http://127.0.0.1:${upstreamPort}${path}`, + headers: { "content-type": "application/json" }, + }, + (res) => { + res.resume(); + res.on("end", () => + resolve({ status: res.statusCode ?? 0 }), + ); + }, + ); + req.on("error", reject); + req.end(body); + }); +}