From 6b3596a77c414f55df616726255a085741a9ed34 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 11 Jul 2026 12:57:52 +0000 Subject: [PATCH 1/7] docs: expand PolyScribe SPEC with hybrid model and full plan Replace the thin MVP outline with a detailed product specification: locked Hybrid (OSS + SaaS + self-host) recommendation, pricing, architecture, LLM rails, security, UX, milestones, and decision log. Co-authored-by: Moses Man --- SPEC.md | 1204 ++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 928 insertions(+), 276 deletions(-) diff --git a/SPEC.md b/SPEC.md index 4aed2c0..fbdcd11 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,139 +1,515 @@ -# PolyScribe — SPEC.md +# PolyScribe — Product Specification + +> **Status:** Planning / pre-implementation +> **Owner:** LatticeAG +> **Repo:** `LatticeAG/PolyScribe` +> **Audience:** founders, eng, design, GTM +> **Rule for this doc:** Prefer a concrete recommendation over an open debate. Open questions are listed only where a human product call is genuinely needed. + +--- + +## 0. Executive Verdict + +### Crucial decision: OSS, SaaS, or Hybrid? + +**Recommendation: Hybrid — open-core CLI/SDK + invite-only hosted SaaS GitHub App.** + +| Option | Verdict | Why | +|--------|---------|-----| +| **Pure OSS** | Reject | Great for trust and adoption, weak for recurring revenue, onboarding, and managed GitHub App ops. | +| **Pure SaaS** | Reject | Release tooling buyers distrust black-box access to diffs/PRs; distribution is harder without an installable CLI; enterprises will demand self-host or auditability. | +| **Hybrid (chosen)** | **Ship this** | Match the category’s buying motion: free local draft for individuals, zero-config GitHub App for teams, self-host path for regulated orgs. | + +**Hybrid shape (locked):** + +1. **MIT OSS** — `@polyscribe/core`, `@polyscribe/cli`, templates, prompt schemas, changelog parser. Fully usable offline with a bring-your-own LLM key (or local model). +2. **Hosted SaaS** — LatticeAG-operated GitHub App, dashboard, queue, billing, audit log, shared LLM routing. Invite-only at launch. +3. **Self-host server** — same app surface as SaaS (`@polyscribe/server` + Docker/Helm), no LatticeAG billing dependency. OSS for community; paid support/Enterprise features later if needed. + +This is the LatticeAG product pattern and the correct fit for PolyScribe: **trust via OSS, convenience and revenue via SaaS, control via self-host.** + +--- ## 1. Problem & User Value -Shipping software is noisy. Every release, maintainers waste hours reading through commits, PRs, and diffs to reconstruct what changed, why it matters, and how to describe it to users. The result is usually a sparse changelog or a manually drafted release note that misses contributions, mischaracterizes risk, and delays launches. +### 1.1 Problem + +Shipping software is noisy. Every release, maintainers reconstruct “what changed and why it matters” from commits, PRs, diffs, labels, and tribal knowledge. Outcomes: + +- Sparse or late changelogs +- Missed contributors and breaking changes +- Inconsistent tone across releases +- Hours burned per release (often 30–90 minutes for serious notes) +- Release managers without an auditable trail of what was communicated + +Existing partial solutions leave gaps: + +| Tool | What it does well | What’s missing | +|------|-------------------|----------------| +| GitHub “Auto-generate release notes” | Fast, free, linked PRs | Shallow prose; no risk/migration editorial; weak monorepo; no review UX | +| Release Drafter | Label → section automation | Template-only; no AI editorial judgment | +| semantic-release / Release Please | Versioning automation | Changelog is mechanical, not narrative | +| Conventional Commits parsers | Structure | Still human-written narrative | +| Generic LLM chat | Flexible prose | No GitHub evidence wiring, no changelog merge, no approval workflow | + +### 1.2 Solution + +**PolyScribe** is a GitHub-native release editor: it ingests commits, PRs, and diffs for a ref range, drafts polished release notes + changelog sections, and supports human approve → publish (GitHub Release and/or changelog PR). + +### 1.3 Value propositions (locked messaging) + +- **Time:** draft in seconds instead of half an hour. +- **Completeness:** every merged PR in range is considered; contributors credited. +- **Quality:** consistent tone, breaking/migration callouts, semver suggestion. +- **Trust:** evidence-linked drafts; OSS path for audit; human approval before publish by default. +- **Fit:** works for OSS maintainers *and* internal platform teams. + +### 1.4 Non-value (do not claim) + +- “Fully automatic perfect release notes with zero review” +- “Replaces your release manager” +- “Marketing blog / Twitter thread generator” (post-MVP) + +--- + +## 2. Product Vision & Principles + +### 2.1 Vision + +Become the default **editorial layer** between git history and public release communication on GitHub — the careful editor, not another noisy ops dashboard. + +### 2.2 Principles (locked) + +1. **Draft, don’t dictate** — human approval is the default; auto-publish is opt-in. +2. **Evidence over eloquence** — every section cites PRs/commits; no orphan claims. +3. **Quiet UI** — one composition, document-first; reduce chrome. +4. **Config as code** — `.polyscribe.yml` is source of truth for repo behavior; dashboard mirrors it. +5. **Local parity** — CLI can do what SaaS does for a single repo, modulo hosted convenience. +6. **Least privilege** — minimal GitHub App permissions; truncate/redact before LLM. +7. **Changelog as artifact** — `CHANGELOG.md` (Keep a Changelog) is first-class, not an afterthought dump. +8. **Fail closed on publish** — never publish without explicit approval unless `autoPublish: true` is set. + +--- + +## 3. Target Users & Jobs-to-be-Done + +### 3.1 Segments + +| Segment | Who | JTBD | Success looks like | +|---------|-----|------|--------------------| +| **OSS maintainers** | Solo / small core teams | “When I cut a tag, help me publish fair, complete notes without a writing session” | Tag → draft → approve → GitHub Release in one sitting | +| **DevRel / product eng** | API companies | “Turn merges into customer-safe release copy with migrations called out” | Breaking + migration sections are reliable | +| **Platform / release managers** | Internal multi-repo orgs | “Standardize release communication across services” | Same structure everywhere; audit trail | +| **Compliance-minded eng leads** | Regulated teams | “Prove what we told users about this version” | Versioned drafts + publish audit log | + +### 3.2 Primary persona (MVP focus) + +**Alex — OSS maintainer / tech lead** +Maintains 1–5 GitHub repos, tags monthly or more, hates writing changelogs, already uses labels inconsistently, will install a GitHub App if setup is under 5 minutes, will try CLI first if skeptical of SaaS. + +### 3.3 Secondary persona (SaaS growth) + +**Riley — release manager at a 50–200 person company** +Owns release process across many services, needs approvers, SSO later, wants org defaults and usage visibility. + +### 3.4 Anti-personas (do not optimize MVP for) + +- Marketing teams wanting blog SEO content +- Non-GitHub forges (GitLab/Bitbucket) users +- Teams wanting fully unattended semver bumps without any human gate + +--- + +## 4. Competitive Positioning + +**Category:** AI-assisted release notes / changelog automation for GitHub. + +**Positioning statement:** +*For GitHub teams who care about release quality, PolyScribe is the evidence-linked release editor that drafts Keep-a-Changelog-ready notes from PRs and diffs — open-source where you need control, hosted where you want zero-config.* + +**Differentiation (locked):** + +1. Evidence pane + draft pane (review UX), not just markdown dump +2. Semver + risk + migration awareness +3. Hybrid distribution (CLI + App + self-host) +4. Changelog merge intelligence (preserve Unreleased / existing structure) +5. Invite-only SaaS quality bar before GA + +**Do not compete on:** being the best version bumper (leave that to Release Please / semantic-release; integrate later via hooks). + +--- + +## 5. Hybrid Business Model (detail) + +### 5.1 What is open (MIT) -PolyScribe is a GitHub App that reads a repository's commits, pull requests, and diffs and drafts polished, ready-to-publish release notes and changelog updates. It turns the post-merge cleanup from a manual chore into a one-click (or fully automated) editorial step. +| Package | Contents | +|---------|----------| +| `@polyscribe/core` | Ingestion adapters, config schema, source normalization, changelog parse/merge, prompt builders, section templates, semver heuristics | +| `@polyscribe/cli` | Local draft/changelog/publish/config commands | +| Templates & examples | Example `.polyscribe.yml`, prompt fixtures, sample changelogs | +| `@polyscribe/server` (community) | Self-hostable GitHub App webhook + API surface sufficient for single-tenant install | -Key value: -- **Saves time** — what used to take 30–90 minutes per release now takes seconds. -- **Reduces omission** — no merged PR is forgotten because the human lost context. -- **Improves quality** — consistent tone, risk labels, migration callouts, and contributor credits. -- **Builds trust** — transparent, well-structured release communication for open-source communities and enterprise customers. +### 5.2 What is hosted SaaS (paid / invite) -## 2. Target Users +| Capability | Why SaaS | +|------------|----------| +| Managed GitHub App install | Zero ops | +| Multi-repo org dashboard | Convenience | +| Managed LLM routing + spend controls | Cost + quality | +| Draft history, audit log, approver roles | Team workflow | +| Org defaults, usage metering, invites | GTM | +| Priority support | Revenue | -| Segment | Who | Pain | How PolyScribe helps | -|---------|-----|------|---------------------| -| Open-source maintainers | Solo maintainers, core teams, foundations | Releases slip because no one writes notes; contributors feel ignored | Auto-drafts notes from merged PRs and credits authors | -| DevRel / product engineers | Teams at API-first companies | Need clear, customer-facing release posts | Generates polished summaries with migration and deprecation sections | -| Enterprise platform teams | Internal platform / infra teams | Many small service releases, hard to audit | Batch drafts per repo or per monorepo package | -| Release managers | Teams with compliance requirements | Need an auditable, structured record of changes | Versioned changelog entries with linked evidence | +### 5.3 Open-core boundary (locked) -## 3. Product Positioning +- **Open:** generation algorithms, templates, CLI, self-host server baseline. +- **Hosted-only initially:** multi-tenant identity, billing, LatticeAG LLM proxy, shared queue, org SSO (later). +- **Avoid:** crippling the OSS CLI so it can’t draft. That kills trust. Monetize convenience, orchestration, and team features — not the ability to summarize a diff. -PolyScribe follows the LatticeAG product pattern: +### 5.4 Licensing -- **Open-source CLI/SDK** (MIT license) — installable locally, auditable, no network required for local inference. -- **Invite-only hosted SaaS** — GitHub App hosted by LatticeAG for teams that want zero-config automation. +- Code: **MIT** for all public packages at launch. +- Brand: “PolyScribe” and “LatticeAG” trademarks retained. +- CLA: **not required** at MVP; DCO (Developer Certificate of Origin) on commits is enough. +- Contribution guide: welcome fixes to core/CLI; SaaS dashboard contributions optional later. -The OSS side builds trust and community contributions; the hosted side delivers speed, moat, and revenue. Self-hosting is possible via the OSS server package; the hosted SaaS is the fastest path. +### 5.5 Pricing (recommendation locked for launch narrative) -## 4. Core Concepts +| Tier | Audience | Limits | Price | +|------|----------|--------|-------| +| **OSS** | Individuals, self-hosters | Unlimited local; BYO LLM | Free | +| **SaaS Starter** | Early invitees | 5 repos, 20 drafts/month, community support | Free during invite | +| **SaaS Team** | Growing orgs | Unlimited repos, 200 drafts/mo included, then metered | **$29 / seat / mo** or **$99 / org / mo** (prefer org flat for simplicity — **recommend org flat $99**) | +| **SaaS Business** | Larger teams | SSO (later), audit export, custom model, higher limits | **$299 / org / mo** | +| **Enterprise** | Regulated | On-prem assist, custom LLM VPC, MSA/DPA, SLA | Contact sales | -### 4.1 Repository -Any GitHub repository PolyScribe is installed on. A repo has configuration, history, and a generated changelog. +**Recommendation:** Prefer **org-based pricing** over per-seat for MVP SaaS — release tools are used by few people across many repos; seat billing creates friction and under-monetizes. -### 4.2 Release -A point-in-time snapshot of changes between two refs (usually two Git tags, or `last tag..HEAD`). Releases are the unit of output. +**Metering unit:** `draft generation` (one LLM job per draft regenerate counts). Approvals/publishes are free. -### 4.3 Changelog -A persistent, versioned markdown file (default `CHANGELOG.md`) that PolyScribe keeps updated. Each release becomes a new section. +--- -### 4.4 Draft -An AI-generated candidate release note or changelog section before human approval. Drafts are stored temporarily and can be edited. +## 6. Core Concepts -### 4.5 Source items -The raw evidence PolyScribe reads: -- **Commits** — messages, authors, stats. -- **Pull requests** — title, body, labels, linked issues, review comments, merge method. -- **Diffs** — file-level and hunk-level changes, renames, deletions. +| Concept | Definition | +|---------|------------| +| **Repository** | GitHub repo with PolyScribe installed or locally configured | +| **Release range** | `fromRef..toRef` (tags/SHAs); default `latest tag..HEAD` | +| **Source item** | Normalized commit, PR, or file change used as evidence | +| **Draft** | AI candidate release note + structured sections + suggested semver | +| **Changelog** | Persistent markdown file (default `CHANGELOG.md`) | +| **Publish target** | `github-release` \| `changelog-pr` \| `both` | +| **Tone** | Editorial voice preset | +| **Installation** | GitHub App install binding (SaaS/self-host) | -### 4.6 Labels -PolyScribe interprets and emits labels: -- **Kind labels**: `feat`, `fix`, `chore`, `docs`, `refactor`, `perf`, `test`, `security`, `breaking`. -- **Risk labels**: `patch`, `minor`, `major` (for semver impact), `migration-required`, `deprecation`. +### 6.1 Kind labels (input + output) -## 5. Core Features +`feat` · `fix` · `chore` · `docs` · `refactor` · `perf` · `test` · `security` · `breaking` -### 5.1 Installable GitHub App -- One-click install for orgs or selected repos. -- Optional event-driven mode: draft release notes automatically after a tag is pushed. -- Optional on-demand mode: generate from `/draft` command or web dashboard. +### 6.2 Risk / impact labels (output) -### 5.2 Multi-source ingestion -- Reads commits, PRs, diffs, and PR review comments. -- Respects `.gitattributes` and ignores generated files by default. -- Groups changes by package in monorepos (via configurable paths or `package.json` / `pyproject.toml` roots). +`patch` · `minor` · `major` · `migration-required` · `deprecation` -### 5.3 AI drafting -- Generates a semver-aware draft with summary, sections, risk callouts, migration notes, and contributor credits. -- Supports multiple editorial tones: `technical`, `developer-friendly`, `executive`, `community`. -- Cites source PRs/commits via links. +### 6.3 Tone presets (locked) -### 5.4 Changelog management -- Appends or inserts entries into `CHANGELOG.md` (or user-specified path). -- Preserves existing formatting and unreleased sections. -- Can keep an `Unreleased` section updated on every merge. +| Tone | Voice | Use when | +|------|-------|----------| +| `technical` | Precise, API-oriented | Libraries, infra | +| `developer-friendly` (**default**) | Clear, friendly, concrete | Most OSS + product APIs | +| `executive` | Short, outcome-focused | Internal stakeholder digests | +| `community` | Warm, credits-forward | Community OSS projects | -### 5.5 Release note export -- Outputs GitHub Release notes, markdown file, or JSON payload. -- One-click publish as a GitHub Release (with tag creation). +--- -### 5.6 Human review workflow -- Dashboard shows draft, diff, source evidence, and suggested semver bump. -- Comments / suggestions can be saved and versioned. -- Approver role required before publish (configurable). +## 7. Scope -### 5.7 CLI (OSS) -- `polyscribe draft` — generate a local draft from a git range. -- `polyscribe changelog` — update a local changelog. -- `polyscribe publish` — push a GitHub Release (requires `GITHUB_TOKEN`). -- `polyscribe config` — interactive configuration wizard. +### 7.1 MVP in scope -### 5.8 Configuration -- Repo-level config via `.polyscribe.yml` or `.github/polyscribe.yml`. -- Org-level defaults via the SaaS dashboard. -- Key knobs: tone, changelog path, ignore globs, monorepo package roots, required approvers, auto-publish rules. +- GitHub App install (org or selected repos) +- Source ingestion: commits, merged PRs, file-level diffs, labels, linked issues +- AI draft with sections + credits + suggested semver +- Dashboard draft review (preview + evidence) +- Approve / discard / regenerate +- Publish GitHub Release +- Changelog update + open PR +- `.polyscribe.yml` config +- OSS CLI: `draft`, `changelog`, `publish`, `config`, `validate-config` +- Ignore globs; basic monorepo path grouping +- Audit log (SaaS) +- Invite-only SaaS gate + +### 7.2 Explicit non-goals (MVP) + +- Marketing blogs, social threads, email digests +- Auto semver bump + tag creation without approval +- GitLab / Bitbucket / Azure DevOps +- Guaranteed hallucination-free output +- Multi-language notes (English only) +- Real-time editing inside GitHub’s native Release UI +- Slack/Linear bots +- Automatic dependency-update noise summarization beyond ignore rules +- Fine-grained per-file LLM review (too expensive) + +### 7.3 Post-MVP candidates (ordered recommendation) + +1. Unreleased section auto-update on merge +2. Conventional Commits / label mapping improvements +3. Monorepo per-package releases +4. Custom prompt overlays in config +5. Export JSON for static sites / docs pipelines +6. Integration with Release Please / changesets +7. Multi-language +8. SSO / SAML +9. Slack approve/publish +10. Public “share draft” links (private by default forever unless opted in) + +--- + +## 8. User Journeys + +### 8.1 Happy path — SaaS tag push + +1. Maintainer installs PolyScribe GitHub App on `acme/api`. +2. Configures `.github/polyscribe.yml` (or accepts defaults). +3. Pushes tag `v1.4.0`. +4. Webhook triggers draft job for `v1.3.0..v1.4.0`. +5. Dashboard shows draft + evidence; Slack email optional later. +6. Maintainer edits lightly, clicks **Approve**, then **Publish** → GitHub Release + changelog PR. +7. Merges changelog PR. + +### 8.2 Happy path — OSS CLI + +1. `npx @polyscribe/cli config --init` +2. Sets `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` (or local endpoint). +3. `polyscribe draft --from v1.3.0 --to HEAD --output RELEASE.md` +4. Reviews file; `polyscribe changelog --write --version 1.4.0` +5. `polyscribe publish --version 1.4.0` with `GITHUB_TOKEN` + +### 8.3 On-demand dashboard draft + +1. Open repo in dashboard → **New draft** +2. Pick range / tone → Generate +3. Review → Approve → Publish targets + +### 8.4 Failure paths (must handle) + +| Failure | UX | +|---------|----| +| No prior tag | Prompt for `fromRef` or first-release mode | +| Empty range | Explain; don’t call LLM | +| LLM timeout | Retry with backoff; show partial sources | +| GitHub rate limit | Queue + retry; surface status | +| Changelog parse failure | Offer overwrite-unreleased or open raw PR | +| Missing permissions | Install remediation checklist | +| Hallucinated API name | Evidence highlighting; regenerate; user edit | + +--- + +## 9. Feature Specifications + +### 9.1 GitHub App + +- Manifest-based create/install +- Org or repo selection +- Webhooks: `push` (tags), `pull_request` (merged, optional Unreleased), `installation`, `installation_repositories` +- Optional: `release` for sync when humans publish manually +- Commands later: issue comment `/polyscribe draft` (post-MVP) + +**Recommendation:** MVP triggers = **manual dashboard/CLI + tag push**. Do not auto-draft on every merge in MVP (noise/cost). + +### 9.2 Multi-source ingestion + +**Collect for range:** + +- Commits (message, author, sha, stats) +- Merged PRs intersecting range (title, body, labels, author, reviewers optional, merge commit) +- Linked issues (number + title; body truncated) +- File changes (path, status, +/-, patch truncated) + +**Filtering (locked defaults):** + +```yaml +ignoreGlobs: + - "**/package-lock.json" + - "**/pnpm-lock.yaml" + - "**/yarn.lock" + - "**/dist/**" + - "**/build/**" + - "**/*.min.js" + - "**/generated/**" +``` + +Respect `.gitattributes` `linguist-generated` when available. + +**PR-first strategy (locked):** Prefer grouping by PR; orphan commits become their own source items. Do not double-count PR commits as separate narrative items. + +### 9.3 AI drafting + +**Output structure (locked default markdown):** + +```markdown +## Summary +... + +## Breaking Changes +... + +## Features +... + +## Fixes +... + +## Performance +... + +## Security +... + +## Documentation +... + +## Maintenance +... + +## Migration Guide +... + +## Contributors +Thanks to @a, @b, ... +``` + +Omit empty sections. Always include Summary + Contributors when any sources exist. + +**Semver suggestion heuristics (deterministic layer before LLM):** + +- Any `breaking` label / `BREAKING CHANGE` footer / major risk → `major` +- Else any `feat` → `minor` +- Else → `patch` +- LLM may confirm/explain but **cannot silently override** without showing the heuristic result; UI shows both if they disagree. + +**Prompt pipeline (locked):** + +1. Normalize + dedupe source items +2. Classify kind/risk with rules + light model assist if ambiguous +3. Truncate diffs (per-file cap, total token budget) +4. Redact secrets via pattern filters +5. Generate structured JSON sections → render markdown template +6. Validate citations: every bullet must reference ≥1 source id + +**Token budget recommendation:** hard cap per draft job (e.g. 100k input tokens equivalent); drop largest patches first, keep PR titles/bodies. + +### 9.4 Changelog management + +- Default path: `CHANGELOG.md` +- Default style: **[Keep a Changelog](https://keepachangelog.com/)** + SemVer headings +- Preserve preamble and existing versions +- Support `## [Unreleased]` section +- Write via branch + PR by default on SaaS (`chore: update changelog for vX.Y.Z`) +- CLI `--write` updates working tree locally + +**Recommendation:** Never force-push to default branch. Always PR on SaaS. + +### 9.5 Publish + +Targets: + +- GitHub Release body = approved markdown (minus optional internal notes) +- Changelog PR +- Both (default when changelog enabled) + +Tag creation: **only if tag missing and user explicitly confirms** in publish dialog. MVP recommendation: require tag to already exist for tag-push flow; allow create-tag checkbox on manual publish. + +### 9.6 Human review workflow + +Roles (SaaS): + +| Role | Powers | +|------|--------| +| Viewer | Read drafts | +| Editor | Generate, edit, regenerate, discard | +| Approver | Approve | +| Admin | Config, members, billing | + +MVP simplification: **any install member with write can edit; Approver required only if `requireApprover: true`** (default `true` for SaaS org installs, `false` for personal user installs). + +### 9.7 CLI (OSS) + +``` +polyscribe draft [--from ] [--to ] [--tone ] [--output ] [--json] +polyscribe changelog [--from ] [--to ] [--version ] [--write] +polyscribe publish [--version ] [--notes ] [--target ] +polyscribe config [--init] +polyscribe validate-config [path] +polyscribe doctor # auth, repo detection, LLM reachability +``` -## 6. Non-Goals +Exit codes: `0` ok, `1` usage/config, `2` GitHub, `3` LLM, `4` changelog parse. + +### 9.8 Configuration schema (locked defaults) + +```yaml +# .polyscribe.yml or .github/polyscribe.yml +changelogPath: CHANGELOG.md +tone: developer-friendly +ignoreGlobs: [ ...defaults... ] +monorepoRoots: [] # e.g. ["packages/*", "apps/*"] +requireApprover: true +autoPublish: false +includeUnreleased: false +publishTargets: [github-release, changelog-pr] +sections: + order: [summary, breaking, features, fixes, perf, security, docs, chore, migration, credits] +includeCommittersWithoutPr: true +maxDiffBytesPerFile: 20000 +maxTotalDiffBytes: 400000 +llm: + # CLI/self-host only; SaaS ignores and uses managed routing + provider: openai # openai | anthropic | openai-compatible + model: gpt-4.1 +``` -The following are intentionally out of scope for the MVP: +Org SaaS defaults can override when repo file absent; **repo file wins** when present. -- Writing marketing blog posts or social threads (export to those formats may come later). -- Automatic semver version bumping / tag creation without explicit approval. -- Support for non-Git forges (GitLab, Bitbucket, etc.) in MVP. -- Real-time editing of release notes inside GitHub's native UI (we provide our own dashboard). -- Guaranteed hallucination-free output — the product is a draft; human review is required. -- Multi-language release notes in MVP (English only). +--- -## 7. Data Model +## 10. Data Model ```ts interface Repository { - id: string; // GitHub node id + id: string; // GitHub node id + installationId: string; owner: string; name: string; defaultBranch: string; config: PolyScribeConfig; - installedAt: string; + installedAt: string; // ISO lastSyncedAt?: string; } interface PolyScribeConfig { - changelogPath: string; // default "CHANGELOG.md" + changelogPath: string; tone: "technical" | "developer-friendly" | "executive" | "community"; - ignoreGlobs: string[]; // e.g. ["package-lock.json", "dist/**"] - monorepoRoots?: string[]; // e.g. ["packages/*"] + ignoreGlobs: string[]; + monorepoRoots?: string[]; requireApprover: boolean; autoPublish: boolean; includeUnreleased: boolean; + publishTargets: Array<"github-release" | "changelog-pr">; + maxDiffBytesPerFile: number; + maxTotalDiffBytes: number; } interface ReleaseRange { - fromRef: string; // tag or sha - toRef: string; // tag or sha (often "HEAD") + fromRef: string; + toRef: string; +} + +interface GitHubUser { + login: string; + id: string; + avatarUrl?: string; } interface SourceItem { @@ -147,15 +523,17 @@ interface SourceItem { mergedAt?: string; labels: string[]; files?: FileChange[]; - linkedIssues?: number[]; + linkedIssues?: Array<{ number: number; title: string }>; + url: string; } interface FileChange { path: string; status: "added" | "modified" | "removed" | "renamed"; + previousPath?: string; additions: number; deletions: number; - patch?: string; + patch?: string; // truncated } interface Draft { @@ -164,15 +542,29 @@ interface Draft { range: ReleaseRange; status: "pending" | "approved" | "published" | "discarded"; suggestedSemver: "patch" | "minor" | "major"; + heuristicSemver: "patch" | "minor" | "major"; markdown: string; sections: DraftSection[]; contributors: GitHubUser[]; + model: { provider: string; name: string }; + createdBy?: string; createdAt: string; updatedAt: string; + publishedAt?: string; } interface DraftSection { - type: "summary" | "breaking" | "features" | "fixes" | "perf" | "docs" | "chore" | "security" | "migration" | "credits"; + type: + | "summary" + | "breaking" + | "features" + | "fixes" + | "perf" + | "docs" + | "chore" + | "security" + | "migration" + | "credits"; title: string; content: string; sourceIds: string[]; @@ -181,230 +573,490 @@ interface DraftSection { interface ChangelogUpdate { repositoryId: string; version: string; - date: string; + date: string; // YYYY-MM-DD markdown: string; prBranch?: string; commitMessage?: string; } + +interface AuditEvent { + id: string; + repositoryId: string; + actor: string; + action: "draft.create" | "draft.regenerate" | "draft.approve" | "draft.discard" | "draft.publish" | "config.update"; + draftId?: string; + metadata?: Record; + createdAt: string; +} ``` -## 8. API / Integration Surface +**Retention (locked recommendation):** -### 8.1 External Services +- Drafts + audit: 1 year on SaaS (exportable on Business+) +- Raw patches in DB: **30 days**, then drop; re-fetch from GitHub if needed +- Source metadata (titles, links, labels): keep with draft lifetime -| Service | Purpose | Auth | Data Used | -|---------|---------|------|-----------| -| GitHub API (REST + GraphQL) | Read commits, PRs, diffs, labels, tags; publish releases; open PRs | GitHub App installation token | Repository metadata, commit/PR/diff content, user handles | -| LLM provider (OpenAI / Anthropic / local) | Summarize changes and generate prose | API key or local model | Aggregated source items (no raw secrets) | -| LatticeAG SaaS identity | Org/repo settings, billing, user accounts | Session cookie / OAuth | Emails, plan tier, usage | +--- -### 8.2 GitHub App Permissions +## 11. API & Integration Surface -| Permission | Reason | -|------------|--------| -| `contents` read/write | Read diffs; write changelog PRs; publish releases | -| `metadata` read | Discover repos and tags | -| `pull_requests` read | Read merged PRs, labels, bodies, comments | -| `issues` read | Read linked issue titles/bodies | -| `workflows` read | Identify CI-related changes (optional) | +### 11.1 External services -Webhook subscriptions: -- `push` (tag pushes) -- `pull_request` (merged) -- `release` (published if manual) +| Service | Purpose | Auth | Notes | +|---------|---------|------|-------| +| GitHub REST + GraphQL | Read/write repo data | GitHub App installation token | Prefer GraphQL for PR lists | +| LLM provider | Draft generation | SaaS: LatticeAG proxy; OSS: user key | Support OpenAI, Anthropic, OpenAI-compatible | +| Email (optional) | Invite + “draft ready” | Provider TBD | Post-MVP ok | +| Payments | SaaS billing | Stripe | Before GA, not before invite | -### 8.3 Internal API Endpoints (SaaS) +### 11.2 GitHub App permissions (least privilege) +| Permission | Access | Reason | +|------------|--------|--------| +| Contents | Read & write | Diffs; changelog commits/PRs; releases | +| Metadata | Read | Repo discovery | +| Pull requests | Read & write | Read merged PRs; open changelog PRs | +| Issues | Read | Linked issue titles | +| Members | Read | Approver resolution (SaaS) | + +**Avoid:** Actions write, Administration, Secrets, Workflows write. + +Webhooks: `push`, `pull_request`, `installation`, `installation_repositories`, `ping`. + +### 11.3 Internal SaaS HTTP API + +``` +GET /api/repos +GET /api/repos/:owner/:name +POST /api/repos/:owner/:name/drafts +GET /api/repos/:owner/:name/drafts +GET /api/drafts/:id +PATCH /api/drafts/:id # edit markdown/sections +POST /api/drafts/:id/regenerate +POST /api/drafts/:id/approve +POST /api/drafts/:id/discard +POST /api/drafts/:id/publish +GET /api/repos/:owner/:name/config +PATCH /api/repos/:owner/:name/config +GET /api/repos/:owner/:name/audit +GET /api/installations +POST /api/invites # LatticeAG admin +``` + +Auth: session cookie after GitHub OAuth; CSRF protection; installation-scoped authorization. + +Idempotency: `POST .../publish` accepts `Idempotency-Key`. + +### 11.4 Webhook → job contract + +```ts +type DraftJob = { + installationId: string; + repositoryId: string; + trigger: "tag_push" | "manual" | "cli_bridge"; + range: ReleaseRange; + tone?: PolyScribeConfig["tone"]; +}; ``` -GET /api/repos - returns: Repository[] -GET /api/repos/:owner/:name - returns: Repository +Queue: at-least-once; workers must be idempotent on `(repositoryId, fromRef, toRef, trigger)` for short window. + +--- + +## 12. UI / UX Specification + +### 12.1 Design direction (locked) + +- **Composition:** document-editor first — draft is the hero, not a widget grid. +- **Palette:** neutral slate base; indigo accent for actions; amber for breaking; emerald for fixes; red only for major risk / destructive. +- **Typography:** distinctive UI sans (not Inter/Roboto/Arial); changelog preview in a readable document face or mono-adjacent for markdown fidelity. +- **Background:** subtle paper/grid atmosphere; avoid flat pure white and generic purple gradients. +- **Cards:** avoid in hero/draft view; use cards only for interactive repo list rows if needed. +- **Motion (2–3 intentional):** + 1. Draft sections stagger-fade on generation complete + 2. Evidence item highlight when clicking a citation + 3. Soft progress pulse while ingesting/generating +- **Feel:** careful editor, quiet, trustworthy. -POST /api/repos/:owner/:name/draft - body: { fromRef, toRef, tone? } - returns: Draft +**Note:** If implementing inside an existing LatticeAG design system later, inherit system tokens — until then, PolyScribe-specific tokens as above. -GET /api/repos/:owner/:name/drafts - returns: Draft[] +### 12.2 Screens -POST /api/drafts/:id/approve - body: { notes? } - returns: Draft +1. **Landing / marketing** (separate from app) — brand-forward, hybrid story, CTA: Request invite / Star on GitHub / `npx` quickstart. +2. **Invite gate** +3. **Repo list** — installed repos, last draft status, last published version +4. **Draft view** — left markdown preview/editor; right evidence; top semver + tone + regenerate; bottom approve/discard/publish +5. **Changelog view** — rendered file + Update Unreleased + Open PR +6. **Settings** — config form, secrets status (LLM for self-host), approvers, webhook log +7. **Audit log** +8. **Billing** (pre-GA stub ok) -POST /api/drafts/:id/publish - body: { version, target?: "github-release" | "changelog-pr" | "both" } - returns: { url, changelogPrUrl?, releaseUrl? } +### 12.3 Accessibility -POST /api/drafts/:id/discard - returns: Draft +- Keyboard approve/publish with confirm +- Contrast AA +- Don’t rely on color alone for risk (icons + text) -GET /api/repos/:owner/:name/config - returns: PolyScribeConfig +### 12.4 Empty / first-run states -PATCH /api/repos/:owner/:name/config - body: Partial - returns: PolyScribeConfig +- No tags yet → guided first release range +- No PRs in range → commit-only mode explanation +- Not installed → install checklist + +--- + +## 13. Architecture + +### 13.1 Logical architecture + +``` +GitHub webhooks ──▶ API gateway ──▶ Queue ──▶ Draft workers ──▶ LLM provider + │ │ + ▼ ▼ + Postgres (repos, drafts, audit) + │ + ▼ + Dashboard (web) ``` -### 8.4 OSS CLI Commands +CLI talks to: local git + GitHub API + LLM directly (no LatticeAG required). + +### 13.2 Package map + +| Package | Runtime | Role | +|---------|---------|------| +| `@polyscribe/core` | Node 22+ / Bun | Shared domain logic | +| `@polyscribe/cli` | Node 22+ / Bun | DX entrypoint | +| `@polyscribe/server` | Node 22+ | App + API for SaaS/self-host | +| `@polyscribe/web` | Next.js App Router | Dashboard UI | +| `@polyscribe/prompt` (optional split) | Node | Prompt templates + JSON schemas | + +### 13.3 Tech stack recommendations (locked for MVP) + +| Layer | Choice | Why | +|-------|--------|-----| +| Language | TypeScript | One language across CLI/server/web | +| Web | Next.js (App Router) on Vercel | LatticeAG fit; dashboard + marketing | +| API | Next route handlers **or** separate Hono/Node worker service | Prefer **separate worker service** for webhook/queue reliability | +| DB | Postgres (Neon) | Relational drafts/audit; Marketplace-friendly | +| Queue | Inngest or Cloudflare Queues / BullMQ | **Recommend Inngest** for DX on Vercel-centric stack | +| Auth | GitHub OAuth + GitHub App | Natural identity | +| Hosting SaaS | Vercel (web) + workers for jobs | Simple | +| Container self-host | Docker + Compose; Helm later | Enterprise path | +| LLM | Anthropic Claude Sonnet + OpenAI GPT-4.1 fallback | Quality/price; abstract provider interface | +| Validation | Zod | Config + API + LLM JSON schema | +| Logging | Structured JSON + OpenTelemetry traces | Debug draft jobs | +| Error monitoring | Sentry | API + worker + web | + +### 13.4 Monorepo recommendation + +**Yes — Turborepo / pnpm workspace** under `LatticeAG/PolyScribe`: ``` -polyscribe draft [--from ] [--to ] [--tone ] [--output ] -polyscribe changelog [--from ] [--to ] [--version ] [--write] -polyscribe publish [--draft-id ] [--version ] [--target ] -polyscribe config [--init] -polyscribe validate-config +apps/web +apps/server +packages/core +packages/cli +packages/config-eslint +packages/tsconfig ``` -## 9. UI Screens / Flows +--- + +## 14. LLM System Design -### 9.1 Dashboard — Repo list -- Table of installed repos. -- Columns: repo, last release, draft status, config link, install more. -- Actions: open repo details, regenerate draft, view changelog. +### 14.1 Provider abstraction -### 9.2 Repo detail — Draft view -- Left pane: generated markdown preview with section tabs (Summary, Breaking, Features, Fixes, etc.). -- Right pane: source evidence (PR/commit list with links to GitHub). -- Top bar: suggested semver, tone selector, regenerate button. -- Bottom bar: edit in place, approve, discard, publish. +```ts +interface LLMClient { + completeStructured(input: { + system: string; + user: string; + schema: ZodSchema; + maxTokens: number; + }): Promise; +} +``` -### 9.3 Repo detail — Changelog -- Rendered preview of current `CHANGELOG.md`. -- "Update Unreleased" button. -- "Open PR" button to propose a changelog update. +### 14.2 Safety rails -### 9.4 Repo settings -- Config form mirroring `PolyScribeConfig`. -- Webhook event log. -- User access / approver list. +- Secret redaction before prompt (AWS keys, tokens, private key blocks) +- Strip `Authorization` headers from patches +- Cap patch size +- Structured output only; reject freeform if citations missing +- Store model name/version on Draft for replay -### 9.5 CLI output -- Progress spinner for ingestion. -- Draft printed to stdout or saved to file. -- Diff preview before `--write`. +### 14.3 Evaluation harness (required before GA) -## 10. Design Direction +- Golden fixture repos (public) with expected section containment +- Metrics: citation coverage, empty-section rate, breaking-change recall on labeled fixtures +- Human eval rubric for invite users (1–5 on accuracy/tone/completeness) -- **Visual language**: clean, document-editor first. Editor occupies the center; metadata is peripheral. -- **Palette**: neutral slate base, indigo accent, amber for breaking changes, emerald for fixes, red for major risk. -- **Typography**: system sans UI; changelog preview uses a narrow proportional serif or mono-adjacent font to evoke documents. -- **Motion**: subtle section reveal on draft generation; link hover states in source evidence panel. -- **Feel**: like a careful editor, not a dashboard. The product's job is to reduce noise, so the UI must be quiet. +**Gate:** do not open public SaaS until citation coverage ≥ 95% on fixtures and invite draft acceptance ≥ 70%. -## 11. Architecture +### 14.4 Cost controls -### 11.1 SaaS deployment +- Per-org monthly draft quota +- Per-job token ceiling +- Cache identical `(repo, from, to, configHash)` drafts for 24h unless force regenerate +--- + +## 15. Security, Privacy, Compliance + +### 15.1 Security model + +- Short-lived GitHub installation tokens only +- Encrypt LLM provider keys at rest (self-host / BYO on SaaS Business+) +- Tenant isolation by `installationId` +- Webhook signature verification mandatory +- CSP, secure cookies, CSRF on dashboard +- No cross-installation draft access +- Admin impersonation: none in MVP + +### 15.2 Privacy + +- Diffs may contain sensitive code — disclose clearly in install docs +- Data Processing: drafts stored in LatticeAG SaaS region (**recommend `us-east` single region MVP**) +- Do not train foundation models on customer repo content by default; contractually forbid with providers where possible +- Retention as in §10 +- Delete-on-uninstall: soft-delete 30 days then hard-delete + +### 15.3 Compliance roadmap + +| Stage | Need | +|-------|------| +| Invite | Privacy policy, security page, DPA draft | +| GA Team | SOC2 start (Type I path), Stripe tax | +| Enterprise | SSO, audit export, VPC/on-prem option, signed BAA only if healthcare push (not default) | + +--- + +## 16. Reliability & Observability + +### 16.1 SLOs (SaaS targets) + +| Metric | Target | +|--------|--------| +| Webhook acknowledge | < 3s | +| Draft job p50 | < 45s | +| Draft job p95 | < 3 min | +| API availability | 99.9% monthly | +| Publish success after approve | ≥ 99% excluding GitHub outages | + +### 16.2 Observability + +- Metrics: draft success/fail, token usage, GitHub API latency, queue depth +- Traces: ingestion → prompt → validate → store +- Alert: spike in LLM validation failures; webhook signature failures; publish errors + +### 16.3 Disaster recovery + +- Postgres automated backups (Neon PITR) +- Queue replay for failed jobs +- Runbooks: GitHub App credential rotation, LLM provider failover + +--- + +## 17. Testing Strategy + +| Layer | What | +|-------|------| +| Unit | Config parse, changelog merge, semver heuristics, redaction | +| Contract | GitHub API mocks; LLM schema validation | +| Fixture e2e | Public repo ranges → draft snapshots (sanitized) | +| CLI smoke | `doctor`, `draft --json` on fixture git repo | +| Web e2e | Install mock → draft → approve → publish mock | +| Load | Burst tag pushes across N repos | + +**Recommendation:** Treat changelog merge and citation validation as the highest-value unit tests — they prevent the worst trust failures. + +--- + +## 18. Documentation Plan + +| Doc | Audience | +|-----|----------| +| README | All — 60-second pitch + CLI quickstart + App install | +| SPEC.md (this) | Builders | +| `docs/config.md` | Config reference | +| `docs/self-hosting.md` | Operators | +| `docs/security.md` | Security reviewers | +| `docs/prompting.md` | Contributors extending prompts | +| Changelog of PolyScribe itself | Dogfood | + +Dogfood rule: **PolyScribe releases are written with PolyScribe** once M3 exists. + +--- + +## 19. Go-to-Market + +### 19.1 Launch sequence (capability-based, not calendar) + +1. Private dogfood on LatticeAG repos +2. Invite OSS maintainers (warm network) +3. Public OSS CLI + core on GitHub +4. Product Hunt / HN only after invite acceptance metric hits gate +5. GA SaaS when billing + quotas ready + +### 19.2 Distribution wedges + +- `npx @polyscribe/cli draft` viral DX +- GitHub Marketplace listing (after permissions review) +- Comparison page vs Release Drafter / auto-notes +- “Migration from Release Drafter” guide + +### 19.3 Success metrics (locked) + +| Metric | Target | +|--------|--------| +| Draft acceptance without heavy edit | > 80% | +| Tag → published release median (approve included human wait excluded for automation metric: tag → draft ready) | draft ready < 2 min p50 | +| Changelog PR merge rate | > 70% | +| Invite → install conversion | > 40% | +| OSS → SaaS conversion (90 days) | track; no hard gate | +| P0 security incidents | 0 in first 90 days | + +--- + +## 20. Risks & Mitigations + +| Risk | Mitigation | +|------|------------| +| LLM hallucinated features | Citation validation; evidence UI; human approve default | +| Cost blowups on huge monorepos | Diff caps; package roots; quotas | +| GitHub permission fatigue | Tight scopes; clear install copy | +| OSS cannibalizes SaaS | Monetize team workflow/hosting, not draft ability | +| Changelog format diversity | Detect common formats; Prefer Keep a Changelog; escape hatch raw mode | +| Secret leakage into prompts | Redaction + patch truncation + provider zero-retention where available | +| Competitor: GitHub improves native AI notes | Win on review UX, changelog merge, hybrid trust, migration sections | + +--- + +## 21. MVP Delivery Plan (capability milestones) + +> No calendar estimates — ship by milestone completion criteria. + +### M1 — App skeleton & identity +**Done when:** GitHub App installs; webhook signatures verified; installation stored; user can sign into empty dashboard. + +### M2 — Ingestion +**Done when:** Given `from..to`, system returns normalized `SourceItem[]` with PRs, commits, filtered files; CLI can print JSON sources. + +### M3 — Draft generation +**Done when:** LLM pipeline produces cited markdown + sections + semver; available via API + CLI; basic web preview. + +### M4 — Changelog +**Done when:** Keep a Changelog merge works on fixtures; CLI `--write`; SaaS opens changelog PR. + +### M5 — Review & publish +**Done when:** Approve/discard/publish to GitHub Release; `requireApprover` enforced; audit events recorded. + +### M6 — Hardening & docs +**Done when:** Retries, doctor command, security doc, self-host compose, dogfood release notes published with PolyScribe; invite flow live. + +--- + +## 22. Open Questions + +Only items that still need a human call. Everything else in this SPEC is a **recommendation treated as default**. + +| # | Question | Recommendation | Needs founder call? | +|---|----------|----------------|---------------------| +| 1 | Brand domain | `polyscribe.latticeag.io` for app; docs under same | Soft — OK unless you have a vanity domain | +| 2 | Org pricing $99 vs per-seat | **Org flat $99 Team** | Confirm before Stripe | +| 3 | Default LLM | Anthropic Claude Sonnet primary, OpenAI fallback | Soft — engineering may swap on evals | +| 4 | GitHub Marketplace at invite vs GA | **GA only** | Confirm | +| 5 | Trademark “PolyScribe” clearance | Run a quick search before public launch | Yes | +| 6 | Whether `@polyscribe/server` is MIT day-one or delayed | **MIT day-one** for trust | Confirm if you fear hosting cannibalization (I still recommend open) | +| 7 | Single-region US only at MVP? | **Yes** | Confirm if EU users are day-one critical | + +### Questions for you (optional replies) + +Reply with preferences only where you disagree; otherwise we treat SPEC defaults as law: + +1. **Pricing:** Org flat Team at ~$99, or do you prefer free-only until PMF? +2. **Auto-draft on tag:** keep default off, or on for SaaS installs? +3. **Server openness:** MIT day-one for `@polyscribe/server`, or open CLI/core only at first? +4. **Primary model:** Claude Sonnet vs GPT-4.1 as default SaaS brain? +5. **EU data residency:** needed for first invites, or US-only OK? +6. **Name lock:** keep **PolyScribe**, or exploring alternates? + +--- + +## 23. Decision Log (accepted defaults) + +| Decision | Choice | +|----------|--------| +| Commercial shape | **Hybrid** (OSS MIT + invite SaaS + self-host) | +| Forge support MVP | GitHub only | +| Changelog style | Keep a Changelog | +| Default tone | `developer-friendly` | +| Auto-publish | Off | +| Approver | Required for org SaaS installs | +| Publish vehicles | GitHub Release + changelog PR | +| Language | English only | +| Monorepo | Path roots supported; auto-detect `packages/*`, `apps/*`, workspace fields | +| Diff storage | 30-day raw patch retention | +| Public draft links | Not in MVP | +| Stack | TS, Next.js, Postgres, Inngest, Zod, Sentry | +| Package manager | pnpm + Turborepo | +| License | MIT | + +--- + +## 24. Appendix A — Example `.polyscribe.yml` + +```yaml +changelogPath: CHANGELOG.md +tone: developer-friendly +requireApprover: true +autoPublish: false +includeUnreleased: false +publishTargets: + - github-release + - changelog-pr +monorepoRoots: + - packages/* +ignoreGlobs: + - "**/package-lock.json" + - "**/pnpm-lock.yaml" + - "**/dist/**" + - "**/generated/**" +maxDiffBytesPerFile: 20000 +maxTotalDiffBytes: 400000 ``` -┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ -│ GitHub App │────▶│ PolyScribe API │────▶│ LLM provider │ -│ (webhooks) │ │ (workers/cloud) │ │ (summarizer) │ -└─────────────────┘ └──────────────────┘ └─────────────────┘ - │ - ▼ - ┌──────────────────┐ - │ Postgres / KV │ - │ (drafts, repos) │ - └──────────────────┘ + +## 25. Appendix B — Example draft (illustrative) + +```markdown +## Summary +This release adds first-class webhook retries and fixes a pagination bug in the installations API. + +## Features +- **Webhook retries** — failed deliveries retry with exponential backoff (@alice, #128) +- **Doctor command** — `polyscribe doctor` checks auth and LLM connectivity (@bob, #134) + +## Fixes +- Installations list no longer drops page 2 results (#131) + +## Migration Guide +Set `POLYSCRIBE_WEBHOOK_MAX_RETRIES` (default 5) if you self-host and need stricter limits. + +## Contributors +Thanks to @alice, @bob, and @carol. ``` -- GitHub App receives webhooks, fetches source items via GitHub API, and stores normalized source data. -- Draft generation job queues work; LLM produces markdown; result stored as `Draft`. -- Human approves via dashboard; publish step calls GitHub API to create release and/or open changelog PR. - -### 11.2 OSS packages - -| Package | Runtime | Purpose | -|---------|---------|---------| -| `@polyscribe/core` | Node / Bun | Ingestion, config parsing, source normalization, changelog templating | -| `@polyscribe/cli` | Node / Bun | CLI commands | -| `@polyscribe/server` | Node / Bun / Deno | Self-hosted GitHub App server | -| `@polyscribe/web` | Browser | Dashboard UI (also used by SaaS) | - -## 12. Security Model - -- **Least-privilege GitHub App**: only the permissions listed in §8.2; no admin or code execution. -- **Installation tokens short-lived**: GitHub App tokens expire after 1 hour; no long-lived PATs in SaaS. -- **No raw secrets in LLM prompts**: source items are summarized; diffs are truncated and filtered. -- **User isolation**: SaaS multi-tenancy by GitHub installation ID; no cross-repo data access. -- **Audit log**: every draft, approve, and publish action is logged with actor and timestamp. -- **Self-hostable**: OSS server can run entirely inside a user's own infrastructure with their own LLM. - -## 13. Deployment Model - -- **OSS CLI/SDK**: published to npm and GitHub Releases under MIT. -- **Self-hosted server**: Docker image + Helm chart; needs `GITHUB_APP_ID`, `GITHUB_PRIVATE_KEY`, `LLM_API_KEY`. -- **Hosted SaaS**: LatticeAG-managed Cloudflare Workers / Deno Deploy cluster with: - - Serverless GitHub App handler - - Queue for draft jobs - - Postgres for persistence - - Dashboard at `https://polyscribe.latticeag.io` - -## 14. 6-Day MVP Milestones - -### M1 — GitHub App skeleton + repo install (Day 1) -- Create GitHub App manifest. -- Implement OAuth/install flow. -- Store installation metadata. -- Verify webhook signature. - -### M2 — Source ingestion (Day 2) -- Fetch commits and merged PRs between two refs. -- Fetch file-level diffs. -- Normalize into `SourceItem` schema. -- Respect ignore globs. - -### M3 — Draft generation (Day 3) -- Build prompt pipeline for LLM. -- Generate markdown with sections and contributor credits. -- Return draft via API/CLI. -- Basic dashboard draft preview. - -### M4 — Changelog update (Day 4) -- Parse and update `CHANGELOG.md` preserving structure. -- CLI `polyscribe changelog --write`. -- Open changelog update as PR (API + dashboard). - -### M5 — Publish + review workflow (Day 5) -- Approve/discard actions. -- Publish as GitHub Release. -- Require-approver enforcement. -- Audit log. - -### M6 — Polish + docs (Day 6) -- README, CLI docs, dashboard copy. -- Error handling and retries. -- Self-hosting quickstart. -- Dogfood on a LatticeAG repo. - -## 15. Pricing & Positioning - -| Tier | Who | Limits | Price | -|------|-----|--------|-------| -| **OSS** | Individuals, self-hosters | Unlimited local use | Free (MIT) | -| **SaaS Starter** | Small teams, early users | 5 repos, 10 drafts/month | Invite-only free | -| **SaaS Growth** | Growing orgs | Unlimited repos, unlimited drafts, priority LLM | Per-seat / per-org (TBD) | -| **Enterprise** | Large orgs | SSO, audit exports, custom LLM, on-prem deploy | Contact sales | - -Early access is invite-only to maintain quality and gather feedback before general availability. OSS remains fully open to prevent lock-in. - -## 16. Open Questions / Decisions - -| # | Decision | Recommendation | Status | -|---|----------|----------------|--------| -| 1 | Default LLM provider for SaaS | OpenAI GPT-4.1 / Anthropic Claude 4 Sonnet with fallback | Pending | -| 2 | Support monorepo auto-detection | Yes, detect `packages/*`, `apps/*`, and `workspaces` fields | Pending | -| 3 | Auto-publish on tag push | Default off; configurable per repo | Pending | -| 4 | Storage for full diff content | Store 30 days then archive; source of truth remains GitHub | Pending | -| 5 | Public dashboard link sharing | Not in MVP; drafts are private to installation | Pending | -| 6 | Changelog format style | Keep a Changelog (https://keepachangelog.com/) by default | Pending | - -## 17. Success Metrics - -- Draft acceptance rate > 80% without heavy editing. -- Median time from tag push to published release < 2 minutes. -- Changelog PR merge rate > 70%. -- Zero P0 security incidents in first 90 days of SaaS. +## 26. Appendix C — Glossary + +| Term | Meaning | +|------|---------| +| Draft | Unpublished AI-generated release note candidate | +| Evidence | Source PRs/commits/diffs backing a claim | +| Installation | GitHub App install binding to account/org | +| Hybrid | OSS + SaaS + self-host distribution | +| Keep a Changelog | Community changelog format standard | + +--- + +*End of SPEC — implement only after this document is accepted or explicitly amended.* From 4c624ae66dd5b4dc5cff2e9fd5bd0e76e791cb8c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 11 Jul 2026 13:05:34 +0000 Subject: [PATCH 2/7] chore: scaffold PolyScribe monorepo (pnpm + turbo + tsup) Co-authored-by: Moses Man --- .gitignore | 4 + .npmrc | 1 + LICENSE | 21 + README.md | 30 + eslint.config.js | 10 + package.json | 22 + packages/cli/package.json | 34 + packages/cli/src/index.ts | 11 + packages/cli/tsconfig.json | 8 + packages/core/package.json | 28 + packages/core/src/index.ts | 3 + packages/core/tsconfig.json | 8 + packages/tsconfig/base.json | 17 + packages/tsconfig/node.json | 7 + pnpm-lock.yaml | 2519 +++++++++++++++++++++++++++++++++++ pnpm-workspace.yaml | 4 + turbo.json | 16 + 17 files changed, 2743 insertions(+) create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 LICENSE create mode 100644 README.md create mode 100644 eslint.config.js create mode 100644 package.json create mode 100644 packages/cli/package.json create mode 100644 packages/cli/src/index.ts create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/core/package.json create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/tsconfig.json create mode 100644 packages/tsconfig/base.json create mode 100644 packages/tsconfig/node.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 turbo.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc5ca18 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.turbo +.env diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..7083917 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +onlyBuiltDependencies[]=esbuild diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..84fb305 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 LatticeAG + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3999e2a --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# PolyScribe + +PolyScribe is a GitHub-native release editor: it ingests commits, PRs, and diffs for a ref range, drafts polished release notes and changelog sections, and supports human approve → publish. + +## OSS-first + +This repository is **open-source first**. `@polyscribe/core` and `@polyscribe/cli` are MIT-licensed and fully usable offline with your own LLM key or local model. Hosted SaaS and self-host options build on the same foundations. + +## Packages + +| Package | Description | +|---------|-------------| +| `@polyscribe/core` | Core library — parsing, drafting, and release logic | +| `@polyscribe/cli` | Command-line interface (`polyscribe`) | + +## Development + +Requires **Node.js 22+** and **pnpm**. + +```bash +pnpm install +pnpm build +pnpm test +pnpm lint +pnpm typecheck +``` + +## License + +MIT — see [LICENSE](./LICENSE). diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..700ffc9 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,10 @@ +import eslint from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + eslint.configs.recommended, + ...tseslint.configs.recommended, + { + ignores: ["**/dist/**", "**/node_modules/**", "**/.turbo/**"], + }, +); diff --git a/package.json b/package.json new file mode 100644 index 0000000..db7217b --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "polyscribe", + "private": true, + "type": "module", + "scripts": { + "build": "turbo run build", + "test": "turbo run test", + "lint": "turbo run lint", + "typecheck": "turbo run typecheck" + }, + "devDependencies": { + "@eslint/js": "^9.28.0", + "eslint": "^9.28.0", + "turbo": "^2.5.4", + "typescript": "^5.8.3", + "typescript-eslint": "^8.34.0" + }, + "packageManager": "pnpm@10.12.1", + "engines": { + "node": ">=22" + } +} diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..435a98f --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,34 @@ +{ + "name": "@polyscribe/cli", + "version": "0.0.0", + "type": "module", + "bin": { + "polyscribe": "./dist/index.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup src/index.ts --format esm --dts --clean", + "test": "vitest run --passWithNoTests", + "lint": "eslint src", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@polyscribe/core": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.15.30", + "tsup": "^8.5.0", + "typescript": "^5.8.3", + "vitest": "^3.2.4" + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 0000000..8b285a8 --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env node +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const packageRoot = dirname(fileURLToPath(import.meta.url)); +const { version } = JSON.parse( + readFileSync(join(packageRoot, "../package.json"), "utf8"), +) as { version: string }; + +console.log(`polyscribe v${version}`); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..9c3708b --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig/node.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..0da1fcb --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,28 @@ +{ + "name": "@polyscribe/core", + "version": "0.0.0", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsup src/index.ts --format esm --dts --clean", + "test": "vitest run --passWithNoTests", + "lint": "eslint src", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^22.15.30", + "tsup": "^8.5.0", + "typescript": "^5.8.3", + "vitest": "^3.2.4" + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..4fecd09 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,3 @@ +// Placeholder — core exports will be added in a follow-up. + +export {}; diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..9c3708b --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig/node.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/tsconfig/base.json b/packages/tsconfig/base.json new file mode 100644 index 0000000..7713070 --- /dev/null +++ b/packages/tsconfig/base.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "bundler", + "module": "ESNext", + "target": "ES2022", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "isolatedModules": true, + "verbatimModuleSyntax": true + } +} diff --git a/packages/tsconfig/node.json b/packages/tsconfig/node.json new file mode 100644 index 0000000..4412ce7 --- /dev/null +++ b/packages/tsconfig/node.json @@ -0,0 +1,7 @@ +{ + "extends": "./base.json", + "compilerOptions": { + "lib": ["ES2022"], + "types": ["node"] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..06bf911 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2519 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@eslint/js': + specifier: ^9.28.0 + version: 9.39.5 + eslint: + specifier: ^9.28.0 + version: 9.39.5 + turbo: + specifier: ^2.5.4 + version: 2.10.4 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.34.0 + version: 8.63.0(eslint@9.39.5)(typescript@5.9.3) + + packages/cli: + dependencies: + '@polyscribe/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: ^22.15.30 + version: 22.20.1 + tsup: + specifier: ^8.5.0 + version: 8.5.1(postcss@8.5.16)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/node@22.20.1)(yaml@2.9.0) + + packages/core: + devDependencies: + '@types/node': + specifier: ^22.15.30 + version: 22.20.1 + tsup: + specifier: ^8.5.0 + version: 8.5.1(postcss@8.5.16)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/node@22.20.1)(yaml@2.9.0) + +packages: + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@turbo/darwin-64@2.10.4': + resolution: {integrity: sha512-m1MUEI4MJ69r5CwfMYxmHi0H0rrgiYCBOp0tgBZ9x/YVvOb5uu/lRIDyDwdtH054R2yWeQaIigUGu6aCX9f8cA==} + cpu: [x64] + os: [darwin] + + '@turbo/darwin-arm64@2.10.4': + resolution: {integrity: sha512-VQ1Yxs5zkPT+2z7t1P4mvn6JmcKLkOCAsPuK9XbOvuVj0DlTlETfIXNisX0771v/vTWHOQqiwoGi+TtAUq8efw==} + cpu: [arm64] + os: [darwin] + + '@turbo/linux-64@2.10.4': + resolution: {integrity: sha512-IzV1QovmwX7mfGnVinmE++2IB8tbeo38weltiuH5zNqwCTBjLs/DytyRKx+bmnhHdXIq9SheR8p0Nip/LBUPHg==} + cpu: [x64] + os: [linux] + + '@turbo/linux-arm64@2.10.4': + resolution: {integrity: sha512-rfujSQkP5aYiRn0PgTM7F00WkJCP/bKDVZbOx3WmrZwa/vHA0bplhCl328kpX7VI9HH2vI90ISGwuSVgJgoqTw==} + cpu: [arm64] + os: [linux] + + '@turbo/windows-64@2.10.4': + resolution: {integrity: sha512-NnspP7Wd5fa3Wwnqv9bKfhegqZzuHBgbPxdZU/idTLQcazx/vgKu95JlCx2YHY0hdvKCnPcARrDwM+KEUmaO7A==} + cpu: [x64] + os: [win32] + + '@turbo/windows-arm64@2.10.4': + resolution: {integrity: sha512-Iv02YgOpaEShc2OkG7mgCJ2pEw1RUKiKbs0h8W5wAf4jZ5vpmraTEjuGTgHRuOORQnC1GN3KHo5WB+hu1abRMA==} + cpu: [arm64] + os: [win32] + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@typescript-eslint/eslint-plugin@8.63.0': + resolution: {integrity: sha512-rvwSgqT+DHpWdzfSzPatRLm02a0GlESt++9iy3hLCDY4BgkaLcl8LBi9Yh7XGFBpwcBE/K3024QuXWTpbz4FfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.63.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.63.0': + resolution: {integrity: sha512-gwh4gvvlaVDKKxyfxMG+Gnu1u9X0OQBwyGLkbwB65dIzBKnxeRiJlNFqlI3zwVhNXJIs6qV7mlFCn/BIajlVig==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.63.0': + resolution: {integrity: sha512-e5dh0/UI0ok53AlZ5wRkXCB32z/f2jUZqPR/ygAw5WYaSw8j9EoJWlS7wQjr/dmOaqWjnPIn2m+HhVPCMWGZVQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.63.0': + resolution: {integrity: sha512-uUyfMWCnDSN8bCpcrY8nGP2BLkQ9Xn0GsipcONcpIDWhwhO4ZSyHvyS14U3X75mzxWxL3I2UZIrenTzdzcJO8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.63.0': + resolution: {integrity: sha512-sUAbkulqBAsncKnbRP3+7CtQFRKicexnj7ZwNC6ddCR7EmrXvjvdCYMJbUIqMd6lwoEriZjwLo08aS5tSjVMHg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.63.0': + resolution: {integrity: sha512-Nzzh/OGxVCOjObjaj1CQF2RUasyYy2Jfuh+zZ3PjLzG2fYRriAiZLib9UKtO+CpQAS3YHiAS+ckZDclwqI1TPA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.63.0': + resolution: {integrity: sha512-xyLtl9DUBBFrcJS4x2pIqGLH68/tC2uOa4Z7pUteW09D3bXnnXUom4dyPikzWgB7llmIc1zoeI3aoUdC4rPK/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.63.0': + resolution: {integrity: sha512-ygBkU+B7ex5UI/gKhaqexWev79uISfIv7XQCRNYO/jmD8rGLPyWLAb3KMRT6nd8Gt9bmUBi9+iX6tBdYfOY81Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.63.0': + resolution: {integrity: sha512-fUKaeAvrTuQg/Tgt3nliAUSZHJM6DlCcfyEmxCvlX8kieWSStBX+5O5Fnidtc3i2JrH+9c/GL4RY2iasd/GPTA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.63.0': + resolution: {integrity: sha512-UexrHGnGTpbuQHct2ExOc2ZcFbGUS9FOesCxxqdBGcpI1BxYu/LZ6U8Aq6/72XtF/qRBk9nhuGHFJIXXMhPMdw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@1.1.16: + resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} + + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.6: + resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + turbo@2.10.4: + resolution: {integrity: sha512-GQpduILaKjoaGljw097ScsSyKTtZSY7cZ3bJktzfTkPMyCf3ShKLuXK2IaOEN2Plziml+ArR7WJ1m+V4VbnaKQ==} + hasBin: true + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.63.0: + resolution: {integrity: sha512-xgwXyzG4sK9ALkBxbyGkTMMOS+imnW65iPhxCQMK83KhxyoDNW7l+IDqEf9vMdoUidHpOoS967RCq4eMiTexwQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.5)': + dependencies: + eslint: 9.39.5 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.6': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.0 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.5': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@turbo/darwin-64@2.10.4': + optional: true + + '@turbo/darwin-arm64@2.10.4': + optional: true + + '@turbo/linux-64@2.10.4': + optional: true + + '@turbo/linux-arm64@2.10.4': + optional: true + + '@turbo/windows-64@2.10.4': + optional: true + + '@turbo/windows-arm64@2.10.4': + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@typescript-eslint/eslint-plugin@8.63.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.63.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.63.0 + '@typescript-eslint/type-utils': 8.63.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/utils': 8.63.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.63.0 + eslint: 9.39.5 + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.63.0(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.63.0 + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.63.0 + debug: 4.4.3 + eslint: 9.39.5 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.63.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.63.0(typescript@5.9.3) + '@typescript-eslint/types': 8.63.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.63.0': + dependencies: + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/visitor-keys': 8.63.0 + + '@typescript-eslint/tsconfig-utils@8.63.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.63.0(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.63.0(eslint@9.39.5)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.5 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.63.0': {} + + '@typescript-eslint/typescript-estree@8.63.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.63.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.63.0(typescript@5.9.3) + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/visitor-keys': 8.63.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.63.0(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5) + '@typescript-eslint/scope-manager': 8.63.0 + '@typescript-eslint/types': 8.63.0 + '@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3) + eslint: 9.39.5 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.63.0': + dependencies: + '@typescript-eslint/types': 8.63.0 + eslint-visitor-keys: 5.0.1 + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@22.20.1)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@22.20.1)(yaml@2.9.0) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + any-promise@1.3.0: {} + + argparse@2.0.1: {} + + assertion-error@2.0.1: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + brace-expansion@1.1.16: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + callsites@3.1.0: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + check-error@2.1.3: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@4.1.1: {} + + concat-map@0.0.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + deep-is@0.1.4: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escape-string-regexp@4.0.0: {} + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.5: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.6 + '@eslint/js': 9.39.5 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.62.2 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + fsevents@2.3.3: + optional: true + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + has-flag@4.0.0: {} + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + joycon@3.1.1: {} + + js-tokens@9.0.1: {} + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.16 + + mlly@1.8.2: + dependencies: + acorn: 8.17.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.15: {} + + natural-compare@1.4.0: {} + + object-assign@4.1.1: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss-load-config@6.0.1(postcss@8.5.16)(yaml@2.9.0): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.16 + yaml: 2.9.0 + + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + punycode@2.3.1: {} + + readdirp@4.1.2: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + source-map@0.7.6: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-json-comments@3.1.1: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.17 + ts-interface-checker: 0.1.13 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + tree-kill@1.2.2: {} + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-interface-checker@0.1.13: {} + + tsup@8.5.1(postcss@8.5.16)(typescript@5.9.3)(yaml@2.9.0): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(postcss@8.5.16)(yaml@2.9.0) + resolve-from: 5.0.0 + rollup: 4.62.2 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.16 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + turbo@2.10.4: + optionalDependencies: + '@turbo/darwin-64': 2.10.4 + '@turbo/darwin-arm64': 2.10.4 + '@turbo/linux-64': 2.10.4 + '@turbo/linux-arm64': 2.10.4 + '@turbo/windows-64': 2.10.4 + '@turbo/windows-arm64': 2.10.4 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.63.0(eslint@9.39.5)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.63.0(@typescript-eslint/parser@8.63.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/parser': 8.63.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.63.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.63.0(eslint@9.39.5)(typescript@5.9.3) + eslint: 9.39.5 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + undici-types@6.21.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite-node@3.2.4(@types/node@22.20.1)(yaml@2.9.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@22.20.1)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6(@types/node@22.20.1)(yaml@2.9.0): + dependencies: + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.16 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + fsevents: 2.3.3 + yaml: 2.9.0 + + vitest@3.2.7(@types/node@22.20.1)(yaml@2.9.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@22.20.1)(yaml@2.9.0)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@22.20.1)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@22.20.1)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.20.1 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + yaml@2.9.0: + optional: true + + yocto-queue@0.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..71b3b8d --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +packages: + - packages/* + +onlyBuiltDependencies: '["esbuild"]' diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..2ee7110 --- /dev/null +++ b/turbo.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://turbo.build/schema.json", + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"] + }, + "test": { + "dependsOn": ["build"] + }, + "lint": {}, + "typecheck": { + "dependsOn": ["^build"] + } + } +} From 151e3952a01a8334961dd0684cf9eb86afa2003f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 11 Jul 2026 13:11:13 +0000 Subject: [PATCH 3/7] feat(oss): implement @polyscribe/core and CLI; expand OSS-first SPEC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Scaffold pnpm/turbo monorepo with @polyscribe/core and @polyscribe/cli - Core: config, git/github ingestion, semver, changelog merge, redaction - Core: LLM draft pipeline (OpenAI + Anthropic) with citation validation - CLI: draft, changelog, config, validate-config, doctor commands - 32 unit tests; build, typecheck, and lint all pass - SPEC §0.1: detailed OSS Phase 1 implementation contract - README quickstart for local development Co-authored-by: Moses Man --- README.md | 17 + SPEC.md | 520 ++++++++++++++- packages/cli/package.json | 4 +- packages/cli/src/commands/changelog.ts | 113 ++++ packages/cli/src/commands/config.ts | 22 + packages/cli/src/commands/doctor.ts | 83 +++ packages/cli/src/commands/draft.ts | 116 ++++ packages/cli/src/commands/validate-config.ts | 35 + packages/cli/src/index.ts | 38 +- packages/core/package.json | 13 +- packages/core/src/changelog/index.ts | 20 + packages/core/src/changelog/merge.ts | 38 ++ packages/core/src/changelog/parse.ts | 79 +++ packages/core/src/config/index.ts | 18 + packages/core/src/config/load.ts | 106 +++ packages/core/src/config/schema.ts | 107 +++ packages/core/src/draft/generate.ts | 152 +++++ packages/core/src/draft/index.ts | 24 + packages/core/src/draft/llm/client.ts | 14 + packages/core/src/draft/llm/factory.ts | 70 ++ packages/core/src/draft/llm/providers.ts | 90 +++ packages/core/src/draft/prompt.ts | 106 +++ packages/core/src/draft/render.ts | 111 ++++ packages/core/src/draft/schema.ts | 25 + packages/core/src/draft/zod-to-json-schema.ts | 72 +++ packages/core/src/git/diff.ts | 69 ++ packages/core/src/git/exec.ts | 24 + packages/core/src/git/log.ts | 31 + packages/core/src/git/refs.ts | 40 ++ packages/core/src/github/client.ts | 43 ++ packages/core/src/github/prs.ts | 79 +++ packages/core/src/index.ts | 10 +- packages/core/src/ingest/collect.ts | 91 +++ packages/core/src/ingest/filter.ts | 12 + packages/core/src/redact/index.ts | 1 + packages/core/src/redact/secrets.ts | 14 + packages/core/src/semver/heuristics.ts | 32 + packages/core/src/semver/index.ts | 1 + packages/core/src/sources/index.ts | 15 + packages/core/src/types.ts | 131 ++++ packages/core/tests/changelog.test.ts | 58 ++ packages/core/tests/config.test.ts | 121 ++++ packages/core/tests/filter.test.ts | 44 ++ packages/core/tests/prompt.test.ts | 83 +++ packages/core/tests/redact.test.ts | 41 ++ packages/core/tests/render.test.ts | 92 +++ packages/core/tests/semver.test.ts | 84 +++ packages/core/tsup.config.ts | 18 + packages/core/vitest.config.ts | 7 + pnpm-lock.yaml | 610 +++++++++++++++++- 50 files changed, 3711 insertions(+), 33 deletions(-) create mode 100644 packages/cli/src/commands/changelog.ts create mode 100644 packages/cli/src/commands/config.ts create mode 100644 packages/cli/src/commands/doctor.ts create mode 100644 packages/cli/src/commands/draft.ts create mode 100644 packages/cli/src/commands/validate-config.ts create mode 100644 packages/core/src/changelog/index.ts create mode 100644 packages/core/src/changelog/merge.ts create mode 100644 packages/core/src/changelog/parse.ts create mode 100644 packages/core/src/config/index.ts create mode 100644 packages/core/src/config/load.ts create mode 100644 packages/core/src/config/schema.ts create mode 100644 packages/core/src/draft/generate.ts create mode 100644 packages/core/src/draft/index.ts create mode 100644 packages/core/src/draft/llm/client.ts create mode 100644 packages/core/src/draft/llm/factory.ts create mode 100644 packages/core/src/draft/llm/providers.ts create mode 100644 packages/core/src/draft/prompt.ts create mode 100644 packages/core/src/draft/render.ts create mode 100644 packages/core/src/draft/schema.ts create mode 100644 packages/core/src/draft/zod-to-json-schema.ts create mode 100644 packages/core/src/git/diff.ts create mode 100644 packages/core/src/git/exec.ts create mode 100644 packages/core/src/git/log.ts create mode 100644 packages/core/src/git/refs.ts create mode 100644 packages/core/src/github/client.ts create mode 100644 packages/core/src/github/prs.ts create mode 100644 packages/core/src/ingest/collect.ts create mode 100644 packages/core/src/ingest/filter.ts create mode 100644 packages/core/src/redact/index.ts create mode 100644 packages/core/src/redact/secrets.ts create mode 100644 packages/core/src/semver/heuristics.ts create mode 100644 packages/core/src/semver/index.ts create mode 100644 packages/core/src/sources/index.ts create mode 100644 packages/core/src/types.ts create mode 100644 packages/core/tests/changelog.test.ts create mode 100644 packages/core/tests/config.test.ts create mode 100644 packages/core/tests/filter.test.ts create mode 100644 packages/core/tests/prompt.test.ts create mode 100644 packages/core/tests/redact.test.ts create mode 100644 packages/core/tests/render.test.ts create mode 100644 packages/core/tests/semver.test.ts create mode 100644 packages/core/tsup.config.ts create mode 100644 packages/core/vitest.config.ts diff --git a/README.md b/README.md index 3999e2a..16e8a6f 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,23 @@ PolyScribe is a GitHub-native release editor: it ingests commits, PRs, and diffs This repository is **open-source first**. `@polyscribe/core` and `@polyscribe/cli` are MIT-licensed and fully usable offline with your own LLM key or local model. Hosted SaaS and self-host options build on the same foundations. +## Quick start + +```bash +# From this repo (development) +pnpm install && pnpm build +node packages/cli/dist/index.js config --init + +# With npm (after publish) +npx @polyscribe/cli config --init +export OPENAI_API_KEY=sk-... # or ANTHROPIC_API_KEY +export GITHUB_TOKEN=ghp_... # optional — enriches with PR metadata + +polyscribe doctor +polyscribe draft --from v0.1.0 --to HEAD --output RELEASE.md +polyscribe changelog --version 0.2.0 --write +``` + ## Packages | Package | Description | diff --git a/SPEC.md b/SPEC.md index fbdcd11..d5fd673 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1,9 +1,10 @@ # PolyScribe — Product Specification -> **Status:** Planning / pre-implementation +> **Status:** OSS Phase 1 — active implementation > **Owner:** LatticeAG > **Repo:** `LatticeAG/PolyScribe` > **Audience:** founders, eng, design, GTM +> **Active scope:** `@polyscribe/core` + `@polyscribe/cli` only. SaaS deferred. > **Rule for this doc:** Prefer a concrete recommendation over an open debate. Open questions are listed only where a human product call is genuinely needed. --- @@ -12,21 +13,476 @@ ### Crucial decision: OSS, SaaS, or Hybrid? -**Recommendation: Hybrid — open-core CLI/SDK + invite-only hosted SaaS GitHub App.** +**Long-term: Hybrid.** **Right now: OSS only.** -| Option | Verdict | Why | -|--------|---------|-----| -| **Pure OSS** | Reject | Great for trust and adoption, weak for recurring revenue, onboarding, and managed GitHub App ops. | -| **Pure SaaS** | Reject | Release tooling buyers distrust black-box access to diffs/PRs; distribution is harder without an installable CLI; enterprises will demand self-host or auditability. | -| **Hybrid (chosen)** | **Ship this** | Match the category’s buying motion: free local draft for individuals, zero-config GitHub App for teams, self-host path for regulated orgs. | +| Option | Long-term | Phase 1 (now) | +|--------|-----------|---------------| +| **Pure OSS** | Part of hybrid | **Ship this first** | +| **Pure SaaS** | Reject alone | Deferred | +| **Hybrid** | **Target end state** | OSS now → SaaS after OSS v0.1 | -**Hybrid shape (locked):** +**Why OSS first (locked):** -1. **MIT OSS** — `@polyscribe/core`, `@polyscribe/cli`, templates, prompt schemas, changelog parser. Fully usable offline with a bring-your-own LLM key (or local model). -2. **Hosted SaaS** — LatticeAG-operated GitHub App, dashboard, queue, billing, audit log, shared LLM routing. Invite-only at launch. -3. **Self-host server** — same app surface as SaaS (`@polyscribe/server` + Docker/Helm), no LatticeAG billing dependency. OSS for community; paid support/Enterprise features later if needed. +1. Validates the core value (ingestion → draft → changelog) without App/billing/queue complexity. +2. Builds trust and community before asking for GitHub App install permissions. +3. CLI is the viral wedge (`npx @polyscribe/cli draft`). +4. SaaS later reuses `@polyscribe/core` unchanged — no throwaway work. -This is the LatticeAG product pattern and the correct fit for PolyScribe: **trust via OSS, convenience and revenue via SaaS, control via self-host.** +**Hybrid end state (deferred, not in active scope):** + +1. **MIT OSS** — `@polyscribe/core`, `@polyscribe/cli`, templates, prompt schemas, changelog parser. +2. **Hosted SaaS** — GitHub App, dashboard, queue, billing (Phase 2+). +3. **Self-host server** — `@polyscribe/server` + Docker (Phase 2+). + +--- + +## 0.1 OSS Phase 1 — Implementation Specification + +> This section is the **active build contract**. Everything here should be implementable without SaaS infrastructure. + +### 0.1.1 Phase 1 goal + +Ship a production-quality **local CLI** that: + +1. Reads git history (+ optional GitHub PRs via `GITHUB_TOKEN`) for a ref range. +2. Generates an evidence-linked AI release draft (BYO LLM key). +3. Merges output into `CHANGELOG.md` (Keep a Changelog). +4. Passes CI: build, typecheck, 30+ unit tests, no live LLM in tests. + +**Phase 1 exit criteria:** + +```bash +pnpm install && pnpm build && pnpm test && pnpm typecheck # all green +polyscribe doctor # passes in a git repo + API key +polyscribe draft --from v0.1.0 --to HEAD --output NOTES.md # produces cited draft +polyscribe changelog --version 0.2.0 --write # updates CHANGELOG.md +``` + +### 0.1.2 Monorepo layout (locked) + +``` +PolyScribe/ +├── package.json # pnpm workspaces root, turbo scripts +├── pnpm-workspace.yaml +├── turbo.json +├── LICENSE # MIT +├── README.md +├── SPEC.md +├── packages/ +│ ├── tsconfig/ +│ │ ├── base.json +│ │ └── node.json +│ ├── core/ # @polyscribe/core +│ │ ├── package.json +│ │ ├── tsup.config.ts +│ │ ├── vitest.config.ts +│ │ ├── src/ +│ │ │ ├── index.ts # public barrel +│ │ │ ├── types.ts +│ │ │ ├── config/ +│ │ │ ├── git/ +│ │ │ ├── github/ +│ │ │ ├── ingest/ +│ │ │ ├── semver/ +│ │ │ ├── changelog/ +│ │ │ ├── redact/ +│ │ │ ├── draft/ +│ │ │ └── sources/ # re-export ingest + git + github +│ │ └── tests/ +│ └── cli/ # @polyscribe/cli +│ ├── package.json # bin: polyscribe +│ └── src/ +│ ├── index.ts +│ └── commands/ +``` + +**Tooling (locked):** + +| Tool | Version | Purpose | +|------|---------|---------| +| Node.js | ≥ 22 | Runtime | +| pnpm | 10.x | Workspaces | +| Turborepo | 2.x | Task orchestration | +| TypeScript | 5.x | Language | +| tsup | 8.x | ESM + DTS builds | +| Vitest | 3.x | Unit tests | +| Zod | 3.x | Config + LLM JSON schema | +| Commander | 12.x | CLI parsing | +| picocolors | 1.x | Terminal colors | + +### 0.1.3 Package: `@polyscribe/core` + +**Purpose:** Pure library — no CLI, no env reads except where explicitly passed in options. All I/O via injected paths/tokens. + +#### Public exports (`src/index.ts`) + +```ts +// types +export type { SourceItem, Draft, DraftSection, ReleaseRange, PolyScribeConfig, ... } + +// config +export { loadConfig, parseConfig, polyScribeConfigSchema, DEFAULT_IGNORE_GLOBS, EXAMPLE_CONFIG_YAML } + +// sources / ingestion +export { collectSources, resolveRange, getLatestTag, resolveRef, applyIgnoreGlobs, ... } + +// semver +export { suggestSemverFromSources } + +// changelog +export { parseChangelog, insertVersion, renderKeepAChangelogBody } + +// draft +export { generateDraft, buildDraftPrompts, renderDraftMarkdown, createLLMClient, CitationValidationError } + +// redact +export { redactSecrets } +``` + +#### Module: `config/` + +| File | API | Behavior | +|------|-----|----------| +| `schema.ts` | `polyScribeConfigSchema` (Zod) | Defaults per §0.1.6 | +| `load.ts` | `loadConfig(cwd)` | Merge `.github/polyscribe.yml` then `.polyscribe.yml`; later file wins on keys | +| `load.ts` | `validateConfigFile(path)` | Parse + validate; throw `ConfigError` with path | + +**Config precedence (locked):** `defaults < .github/polyscribe.yml < .polyscribe.yml < CLI flags` + +#### Module: `git/` + +| File | API | Behavior | +|------|-----|----------| +| `exec.ts` | `git(cwd, args[])` | Wrapper around `execa('git', ...)` with cwd | +| `refs.ts` | `resolveRef(cwd, ref)` | `git rev-parse` → full SHA | +| `refs.ts` | `getLatestTag(cwd)` | `git describe --tags --abbrev=0` or null | +| `refs.ts` | `resolveRange(cwd, from?, to?)` | Default: `latestTag..HEAD`; if no tags, `root..HEAD` | +| `log.ts` | `getCommitsInRange(cwd, fromSha, toSha)` | Delimited `git log`; returns `CommitInfo[]` | +| `diff.ts` | `getFileChanges(cwd, fromSha, toSha, opts)` | `git diff --name-status` + `--numstat` + per-file patch; apply caps | + +**Git log format (locked):** Use a unique record delimiter (`\x1e`) between commits and `\x1f` between fields to avoid parsing fragility: + +``` +sha\x1fauthorName\x1fauthorEmail\x1fdate\x1ftitle\x1fbody +``` + +#### Module: `github/` + +| File | API | Behavior | +|------|-----|----------| +| `client.ts` | `detectRemoteRepo(cwd)` | Parse `origin` URL → `{ owner, repo, host }` | +| `client.ts` | `createOctokit(token?)` | `@octokit/rest` instance | +| `client.ts` | `getCommitDate(cwd, sha)` | For PR date window | +| `prs.ts` | `fetchMergedPrsInRange(remote, token?, since, until)` | Paginate `pulls.list` state=closed; filter `merged_at` in range | + +**PR mapping → `SourceItem` (locked):** + +```ts +{ + id: `pr:${number}`, + type: "pr", + prNumber: number, + sha: merge_commit_sha, + title, body, author, labels, linkedIssues, url, + mergedAt: merged_at, +} +``` + +**Without `GITHUB_TOKEN`:** ingestion still works from git commits + diffs; PR bodies/labels unavailable. + +#### Module: `ingest/` + +| File | API | Behavior | +|------|-----|----------| +| `filter.ts` | `applyIgnoreGlobs(paths, globs)` | `minimatch` with `dot: true` | +| `collect.ts` | `collectSources(cwd, range, config)` | Orchestrate; **PR-first dedupe** | + +**`collectSources` algorithm (locked):** + +1. Resolve range → `fromSha`, `toSha` +2. Parallel fetch: commits, file changes, remote metadata +3. If remote + token: fetch merged PRs in commit-date window +4. Build `prCommitShas` from PR merge SHAs +5. Orphan commits = commits not in `prCommitShas` +6. Diff items: top 20 file changes as `type: "diff"` evidence (path + truncated patch) +7. Return `[...prItems, ...orphanCommitItems, ...diffItems]` + +#### Module: `semver/heuristics.ts` + +**`suggestSemverFromSources(sources)` (locked rules):** + +1. `major` if any source has label `breaking` OR title/body matches `/BREAKING CHANGE/i` +2. else `minor` if any label `feat` OR conventional `feat:` in commit title +3. else `patch` + +Returns `{ heuristic: SemverLevel, reasons: string[] }` for UI/debug. + +#### Module: `changelog/` + +| File | API | Behavior | +|------|-----|----------| +| `parse.ts` | `parseChangelog(markdown)` | Extract `preamble`, `unreleased`, `versions[]` | +| `merge.ts` | `insertVersion(doc, version, date, body)` | Insert after Unreleased or after preamble | +| `render.ts` | `renderKeepAChangelogBody(sections)` | `### Added/Changed/Fixed/...` from draft sections | + +**Supported changelog headers (locked):** + +```markdown +## [Unreleased] +## [1.2.3] - 2026-07-11 +## 1.2.3 (2026-07-11) # also parse, normalize on write +``` + +#### Module: `redact/secrets.ts` + +**Patterns to redact before LLM (locked):** + +| Pattern | Replacement | +|---------|-------------| +| AWS access key `AKIA...` | `[REDACTED_AWS_KEY]` | +| GitHub `ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_` | `[REDACTED_GITHUB_TOKEN]` | +| OpenAI `sk-...` | `[REDACTED_OPENAI_KEY]` | +| PEM `-----BEGIN ... PRIVATE KEY-----` blocks | `[REDACTED_PRIVATE_KEY]` | +| `Bearer eyJ...` JWTs | `[REDACTED_BEARER_TOKEN]` | + +Apply to: commit messages, PR bodies, diff patches. + +#### Module: `draft/` + +**Pipeline (locked order):** + +``` +sources → redactSecrets → buildDraftPrompts → LLM structured JSON → validate citations → renderDraftMarkdown → Draft +``` + +| File | Responsibility | +|------|----------------| +| `prompt.ts` | System + user prompts; tone instructions; source catalog with ids | +| `schema.ts` | Zod `llmDraftOutputSchema`: `{ sections: [{type, title, content, sourceIds}], contributors, suggestedSemver? }` | +| `generate.ts` | `generateDraft(sources, config, llmClient)` + `CitationValidationError` | +| `render.ts` | Section order from config; omit empty; `## Contributors` last | +| `llm/client.ts` | `LLMClient.completeStructured({ system, user, schema, maxTokens })` | +| `llm/providers.ts` | `OpenAILLMClient`, `AnthropicLLMClient` | +| `llm/factory.ts` | `createLLMClient(config?)` reads env | + +**Citation validation (locked):** + +- Every non-empty section (except `credits`) must have ≥1 valid `sourceId` OR inline `(#123)` / `(source:...)` +- Unknown `sourceId` → `CitationValidationError` +- On validation failure: retry once with repair prompt; then throw + +**LLM defaults (OSS, locked):** + +| Env | Default | +|-----|---------| +| `POLYSCRIBE_LLM_PROVIDER` | `openai` if `OPENAI_API_KEY`, else `anthropic` if `ANTHROPIC_API_KEY` | +| `OPENAI_API_KEY` | required for OpenAI | +| `ANTHROPIC_API_KEY` | required for Anthropic | +| Model (OpenAI) | `gpt-4.1` | +| Model (Anthropic) | `claude-sonnet-4-20250514` | +| `maxTokens` | 4096 output | + +**Structured output:** Use provider native JSON mode / tool schema where available; fall back to `zod-to-json-schema.ts` + parse + Zod safeParse. + +#### Tone prompt fragments (locked) + +| Tone | System instruction fragment | +|------|----------------------------| +| `developer-friendly` | Clear, concrete, second-person optional; explain user impact; no hype | +| `technical` | Precise API names; mention endpoints, types, config keys | +| `executive` | 2–3 sentence summary; outcomes over implementation | +| `community` | Warm; emphasize contributors; thank by @handle | + +### 0.1.4 Package: `@polyscribe/cli` + +**Entry:** `packages/cli/src/index.ts` — Commander program, `run()` for testing. + +#### Commands (Phase 1) + +| Command | Status | Description | +|---------|--------|-------------| +| `polyscribe draft` | **Implemented** | Generate AI draft | +| `polyscribe changelog` | **Implemented** | Draft + merge into CHANGELOG | +| `polyscribe config --init` | **Implemented** | Write example `.polyscribe.yml` | +| `polyscribe validate-config` | **Implemented** | Validate config file | +| `polyscribe doctor` | **Implemented** | Preflight checks | +| `polyscribe publish` | **Phase 1b** | Create GitHub Release via API | +| `polyscribe sources` | **Phase 1b** | Print collected sources as JSON (debug) | + +#### `polyscribe draft` (spec) + +``` +polyscribe draft [options] + +Options: + --from Start ref (default: latest tag or root) + --to End ref (default: HEAD) + --tone Override config tone + --output Write markdown to file + --json Print Draft JSON to stdout + +Exit codes: 0 ok | 1 config/usage | 2 git | 3 LLM | 4 citation validation +``` + +**Stdout (default):** rendered markdown +**Stderr:** progress (`Collecting sources...`, `Generating draft...`) + +#### `polyscribe changelog` (spec) + +``` +polyscribe changelog [options] + +Options: + --from Same as draft + --to + --version Required unless interactive prompt + --write Write to changelogPath from config + --date Default: today UTC + +Flow: + 1. collectSources + generateDraft (or reuse cached draft — post-MVP) + 2. renderKeepAChangelogBody(sections) + 3. read changelogPath → insertVersion → write if --write else stdout +``` + +#### `polyscribe doctor` (spec) + +Checks (print ✓/✗ each): + +1. Inside a git repository +2. `git remote -v` has parseable origin +3. `GITHUB_TOKEN` set (optional, warn if missing) +4. LLM key reachable (`createLLMClient` + minimal ping or model list) +5. Config file valid if present +6. `changelogPath` exists or parent writable + +### 0.1.5 Environment variables + +| Variable | Required | Purpose | +|----------|----------|---------| +| `OPENAI_API_KEY` | One of LLM keys | OpenAI draft generation | +| `ANTHROPIC_API_KEY` | One of LLM keys | Anthropic draft generation | +| `POLYSCRIBE_LLM_PROVIDER` | No | Force `openai` \| `anthropic` | +| `GITHUB_TOKEN` | No | Enrich with PR metadata | +| `POLYSCRIBE_CONFIG` | No | Override config file path | + +### 0.1.6 Default `.polyscribe.yml` (locked) + +```yaml +changelogPath: CHANGELOG.md +tone: developer-friendly +ignoreGlobs: + - "**/package-lock.json" + - "**/pnpm-lock.yaml" + - "**/yarn.lock" + - "**/bun.lockb" + - "**/dist/**" + - "**/build/**" + - "**/generated/**" + - "**/*.min.js" +monorepoRoots: [] +requireApprover: true # relevant for SaaS; ignored by CLI publish in Phase 1 +autoPublish: false +includeUnreleased: false +publishTargets: + - github-release + - changelog-pr +includeCommittersWithoutPr: true +maxDiffBytesPerFile: 20000 +maxTotalDiffBytes: 400000 +sections: + order: + - summary + - breaking + - features + - fixes + - perf + - security + - docs + - chore + - migration + - credits +# llm: # optional CLI override +# provider: openai +# model: gpt-4.1 +``` + +### 0.1.7 Test matrix (Phase 1) + +| Suite | File | Cases | Live deps | +|-------|------|-------|-----------| +| Config | `tests/config.test.ts` | defaults, merge, loadConfig | none | +| Semver | `tests/semver.test.ts` | patch/minor/major/breaking | none | +| Changelog | `tests/changelog.test.ts` | parse, insert Unreleased | none | +| Redact | `tests/redact.test.ts` | secret patterns | none | +| Filter | `tests/filter.test.ts` | ignore globs | none | +| Prompt | `tests/prompt.test.ts` | tone, source catalog | none | +| Render | `tests/render.test.ts` | section order, empty omit | none | +| Git integration | `tests/git.integration.test.ts` | **Phase 1b** | local git fixture repo | +| Citation | `tests/citation.test.ts` | **Phase 1b** | none | + +**CI command (locked):** `pnpm build && pnpm test && pnpm typecheck && pnpm lint` + +**Never in CI:** live LLM calls, live GitHub API calls. + +### 0.1.8 Implementation status tracker + +| Component | Status | Notes | +|-----------|--------|-------| +| Monorepo scaffold | ✅ Done | pnpm + turbo + tsup | +| `config/` | ✅ Done | Zod schema + load | +| `git/` refs, log, diff | ✅ Done | | +| `github/` client, prs | ✅ Done | optional token | +| `ingest/collect` | ✅ Done | PR-first dedupe | +| `semver/heuristics` | ✅ Done | | +| `changelog/` parse + merge | ✅ Done | | +| `redact/secrets` | ✅ Done | | +| `draft/` pipeline | ✅ Done | OpenAI + Anthropic | +| CLI draft/changelog/config/doctor | ✅ Done | | +| Unit tests (32) | ✅ Done | | +| `polyscribe publish` | ⬜ Phase 1b | GitHub Releases API | +| `polyscribe sources` | ⬜ Phase 1b | debug JSON export | +| Git integration tests | ⬜ Phase 1b | fixture repo | +| Citation unit tests | ⬜ Phase 1b | | +| `docs/cli.md` | ⬜ Phase 1b | | +| Dogfood on LatticeAG repo | ⬜ Phase 1b | | +| npm publish `@polyscribe/*` | ⬜ Phase 1c | | + +### 0.1.9 OSS milestones (active) + +| ID | Done when | Status | +|----|-----------|--------| +| **O1** Scaffold + build | `pnpm build` green | ✅ | +| **O2** Ingestion | `collectSources` returns PRs+commits+diffs | ✅ | +| **O3** Draft | `generateDraft` + CLI `draft` | ✅ | +| **O4** Changelog | `insertVersion` + CLI `changelog --write` | ✅ | +| **O5** Tests | ≥30 unit tests, no live LLM | ✅ | +| **O6** Publish | `polyscribe publish` creates GitHub Release | ⬜ | +| **O7** Docs + npm | README quickstart + npm publish | ⬜ | +| **O8** Dogfood | PolyScribe release notes via PolyScribe | ⬜ | + +### 0.1.10 Phase 1b recommendations (next build tasks) + +1. **`polyscribe publish`** — `POST /repos/{owner}/{repo}/releases` with `tag_name`, `body`, `draft: false`; require existing tag. +2. **`polyscribe sources --json`** — expose ingestion for debugging without LLM spend. +3. **Fixture git repo** in `packages/core/tests/fixtures/repo/` for integration tests. +4. **`CitationValidationError` tests** — bullet without sourceId fails; repair retry mocked. +5. **Config `llm.model` override** — wire through `createLLMClient`. +6. **Conventional commit detection** — `feat:` / `fix:` in commit titles augments semver heuristics. +7. **Rate limit handling** — Octokit retry plugin for GitHub 403/429. +8. **`polyscribe changelog --dry-run`** — print diff of changelog change without write. + +### 0.1.11 Explicitly deferred (Phase 2+) + +Do **not** build until OSS v0.1 ships: + +- GitHub App + webhooks +- Dashboard (`@polyscribe/web`) +- `@polyscribe/server` multi-tenant +- Postgres, queues, billing, invites +- Auto-draft on tag push +- Approver roles / audit log UI +- SSO, org defaults --- @@ -933,27 +1389,42 @@ Dogfood rule: **PolyScribe releases are written with PolyScribe** once M3 exists --- -## 21. MVP Delivery Plan (capability milestones) +## 21. Delivery Plan + +### 21.1 OSS Phase 1 (active — see §0.1) + +| ID | Milestone | Status | +|----|-----------|--------| +| O1 | Scaffold + build | ✅ | +| O2 | Ingestion (`collectSources`) | ✅ | +| O3 | Draft generation + CLI `draft` | ✅ | +| O4 | Changelog merge + CLI `changelog` | ✅ | +| O5 | Unit tests (32+) | ✅ | +| O6 | `polyscribe publish` | ⬜ Phase 1b | +| O7 | npm publish + docs | ⬜ Phase 1c | +| O8 | Dogfood release | ⬜ Phase 1c | + +### 21.2 SaaS Phase 2+ (deferred) -> No calendar estimates — ship by milestone completion criteria. +> Do not start until OSS v0.1 is on npm and dogfooded. ### M1 — App skeleton & identity **Done when:** GitHub App installs; webhook signatures verified; installation stored; user can sign into empty dashboard. -### M2 — Ingestion -**Done when:** Given `from..to`, system returns normalized `SourceItem[]` with PRs, commits, filtered files; CLI can print JSON sources. +### M2 — Source ingestion (SaaS) +**Done when:** Same as O2 but via webhook-triggered jobs. -### M3 — Draft generation -**Done when:** LLM pipeline produces cited markdown + sections + semver; available via API + CLI; basic web preview. +### M3 — Draft generation (SaaS) +**Done when:** LLM pipeline produces cited markdown + sections + semver; available via API + dashboard. -### M4 — Changelog -**Done when:** Keep a Changelog merge works on fixtures; CLI `--write`; SaaS opens changelog PR. +### M4 — Changelog (SaaS) +**Done when:** Keep a Changelog merge works; SaaS opens changelog PR. -### M5 — Review & publish +### M5 — Review & publish (SaaS) **Done when:** Approve/discard/publish to GitHub Release; `requireApprover` enforced; audit events recorded. -### M6 — Hardening & docs -**Done when:** Retries, doctor command, security doc, self-host compose, dogfood release notes published with PolyScribe; invite flow live. +### M6 — Hardening & docs (SaaS) +**Done when:** Retries, security doc, self-host compose, invite flow live. --- @@ -988,7 +1459,8 @@ Reply with preferences only where you disagree; otherwise we treat SPEC defaults | Decision | Choice | |----------|--------| -| Commercial shape | **Hybrid** (OSS MIT + invite SaaS + self-host) | +| **Active phase** | **OSS only** (`@polyscribe/core` + `@polyscribe/cli`) | +| Commercial shape (long-term) | **Hybrid** (OSS MIT + invite SaaS + self-host) | | Forge support MVP | GitHub only | | Changelog style | Keep a Changelog | | Default tone | `developer-friendly` | diff --git a/packages/cli/package.json b/packages/cli/package.json index 435a98f..421801f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -23,7 +23,9 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@polyscribe/core": "workspace:*" + "@polyscribe/core": "workspace:*", + "commander": "^13.0.0", + "picocolors": "^1.1.1" }, "devDependencies": { "@types/node": "^22.15.30", diff --git a/packages/cli/src/commands/changelog.ts b/packages/cli/src/commands/changelog.ts new file mode 100644 index 0000000..f027bc6 --- /dev/null +++ b/packages/cli/src/commands/changelog.ts @@ -0,0 +1,113 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import type { Command } from "commander"; +import pc from "picocolors"; +import { + collectSources, + createEmptyChangelog, + createLLMClient, + generateDraft, + insertVersion, + loadConfig, + renderKeepAChangelogBody, + resolveRange, +} from "@polyscribe/core"; + +export interface ChangelogCommandOptions { + from?: string; + to?: string; + version?: string; + write?: boolean; +} + +export function registerChangelogCommand(program: Command): void { + program + .command("changelog") + .description("Generate and optionally write Keep a Changelog entry") + .option("--from ", "start ref") + .option("--to ", "end ref (default: HEAD)") + .option("--version ", "version for changelog heading (required with --write)") + .option("--write", "update changelog file in working tree") + .action(async (options: ChangelogCommandOptions) => { + const cwd = process.cwd(); + const { config } = loadConfig(cwd); + const changelogPath = resolve(cwd, config.changelogPath); + + const range = await resolveRange( + cwd, + options.from, + options.to ?? "HEAD", + ); + + console.log(pc.dim("Collecting sources...")); + const sources = await collectSources( + cwd, + { + fromRef: range.fromRef, + toRef: range.toRef, + }, + { + ignoreGlobs: config.ignoreGlobs, + maxDiffBytesPerFile: config.maxDiffBytesPerFile, + maxTotalDiffBytes: config.maxTotalDiffBytes, + githubToken: process.env.GITHUB_TOKEN, + }, + ); + + if (sources.length === 0) { + console.error( + pc.yellow( + `No sources found for range ${range.fromRef}..${range.toRef}`, + ), + ); + process.exit(1); + } + + const llm = createLLMClient({ + provider: config.llm?.provider, + model: config.llm?.model, + }); + + console.log(pc.dim(`Generating changelog body with ${llm.provider}/${llm.model}...`)); + + const draft = await generateDraft( + sources, + { + tone: config.tone, + repositoryId: "local", + range: { fromRef: range.fromRef, toRef: range.toRef }, + }, + llm, + ); + + const body = renderKeepAChangelogBody( + draft.sections, + config.sections?.order, + ); + + if (!options.write) { + console.log(""); + console.log(body); + console.log(""); + console.log(pc.dim(`Range: ${range.fromRef}..${range.toRef}`)); + return; + } + + if (!options.version) { + console.error(pc.red("--version is required when using --write")); + process.exit(1); + } + + const existing = existsSync(changelogPath) + ? readFileSync(changelogPath, "utf8") + : createEmptyChangelog(); + + const date = new Date().toISOString().slice(0, 10); + const updated = insertVersion(existing, options.version, date, body); + + writeFileSync(changelogPath, updated, "utf8"); + console.log( + pc.green(`Updated ${config.changelogPath} for v${options.version}`), + ); + }); +} diff --git a/packages/cli/src/commands/config.ts b/packages/cli/src/commands/config.ts new file mode 100644 index 0000000..f28fbce --- /dev/null +++ b/packages/cli/src/commands/config.ts @@ -0,0 +1,22 @@ +import { writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import type { Command } from "commander"; +import pc from "picocolors"; +import { EXAMPLE_CONFIG_YAML } from "@polyscribe/core"; + +export function registerConfigCommand(program: Command): void { + const configCmd = program + .command("config") + .description("Manage PolyScribe configuration"); + + configCmd + .command("init") + .description("Write example .polyscribe.yml in the current directory") + .option("--path ", "config file path", ".polyscribe.yml") + .action((options: { path: string }) => { + const target = resolve(process.cwd(), options.path); + writeFileSync(target, EXAMPLE_CONFIG_YAML, "utf8"); + console.log(pc.green(`Created ${options.path}`)); + console.log(pc.dim("Edit tone, ignoreGlobs, and llm settings as needed.")); + }); +} diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts new file mode 100644 index 0000000..fafb807 --- /dev/null +++ b/packages/cli/src/commands/doctor.ts @@ -0,0 +1,83 @@ +import { execSync } from "node:child_process"; +import type { Command } from "commander"; +import pc from "picocolors"; +import { findConfigPath, hasLLMCredentials, loadConfig } from "@polyscribe/core"; + +function isGitRepo(cwd: string): boolean { + try { + execSync("git rev-parse --git-dir", { cwd, stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +function checkGitHubToken(): { ok: boolean; message: string } { + if (process.env.GITHUB_TOKEN) { + return { ok: true, message: "GITHUB_TOKEN is set" }; + } + return { + ok: true, + message: "GITHUB_TOKEN not set (optional for local draft/changelog)", + }; +} + +function checkLLM(configProvider?: string): { ok: boolean; message: string } { + const provider = configProvider as "openai" | "anthropic" | undefined; + if (hasLLMCredentials(provider)) { + const resolved = + provider ?? (process.env.ANTHROPIC_API_KEY ? "anthropic" : "openai"); + return { ok: true, message: `LLM credentials found for ${resolved}` }; + } + return { + ok: false, + message: "No LLM API key found (set OPENAI_API_KEY or ANTHROPIC_API_KEY)", + }; +} + +export function registerDoctorCommand(program: Command): void { + program + .command("doctor") + .description("Check git repo, optional GITHUB_TOKEN, and LLM credentials") + .action(() => { + const cwd = process.cwd(); + const { config } = loadConfig(cwd); + let failed = false; + + console.log(pc.bold("PolyScribe doctor\n")); + + const gitOk = isGitRepo(cwd); + console.log( + `${gitOk ? pc.green("✓") : pc.red("✗")} Git repository: ${ + gitOk ? "yes" : "not detected" + }`, + ); + if (!gitOk) failed = true; + + const github = checkGitHubToken(); + console.log( + `${github.ok ? pc.green("✓") : pc.yellow("!")} GitHub: ${github.message}`, + ); + + const llm = checkLLM(config.llm?.provider); + console.log( + `${llm.ok ? pc.green("✓") : pc.red("✗")} LLM: ${llm.message}`, + ); + if (!llm.ok) failed = true; + + const configPath = findConfigPath(cwd); + console.log( + `${configPath ? pc.green("✓") : pc.yellow("!")} Config: ${ + configPath ?? "using defaults (run polyscribe config init)" + }`, + ); + + console.log(""); + if (failed) { + console.log(pc.red("Some checks failed.")); + process.exit(1); + } + + console.log(pc.green("All checks passed.")); + }); +} diff --git a/packages/cli/src/commands/draft.ts b/packages/cli/src/commands/draft.ts new file mode 100644 index 0000000..513e920 --- /dev/null +++ b/packages/cli/src/commands/draft.ts @@ -0,0 +1,116 @@ +import { writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import type { Command } from "commander"; +import pc from "picocolors"; +import { + collectSources, + createLLMClient, + generateDraft, + loadConfig, + resolveRange, +} from "@polyscribe/core"; + +export interface DraftCommandOptions { + from?: string; + to?: string; + tone?: string; + output?: string; + json?: boolean; +} + +export function registerDraftCommand(program: Command): void { + program + .command("draft") + .description("Generate AI release notes draft from git history") + .option("--from ", "start ref (default: latest tag or root commit)") + .option("--to ", "end ref (default: HEAD)") + .option( + "--tone ", + "editorial tone: technical | developer-friendly | executive | community", + ) + .option("--output ", "write markdown to file") + .option("--json", "output draft as JSON") + .action(async (options: DraftCommandOptions) => { + const cwd = process.cwd(); + const { config } = loadConfig(cwd); + const tone = (options.tone ?? config.tone) as typeof config.tone; + + const range = await resolveRange( + cwd, + options.from, + options.to ?? "HEAD", + ); + + console.log(pc.dim("Collecting sources...")); + const sources = await collectSources( + cwd, + { + fromRef: range.fromRef, + toRef: range.toRef, + }, + { + ignoreGlobs: config.ignoreGlobs, + maxDiffBytesPerFile: config.maxDiffBytesPerFile, + maxTotalDiffBytes: config.maxTotalDiffBytes, + githubToken: process.env.GITHUB_TOKEN, + }, + ); + + if (sources.length === 0) { + console.error( + pc.yellow( + `No sources found for range ${range.fromRef}..${range.toRef}`, + ), + ); + process.exit(1); + } + + console.log( + pc.dim( + `Found ${sources.length} source(s) in ${range.fromRef}..${range.toRef}`, + ), + ); + + const llm = createLLMClient({ + provider: config.llm?.provider, + model: config.llm?.model, + }); + + console.log(pc.dim(`Generating draft with ${llm.provider}/${llm.model}...`)); + + const draft = await generateDraft( + sources, + { + tone, + repositoryId: "local", + range: { fromRef: range.fromRef, toRef: range.toRef }, + }, + llm, + ); + + if (options.json) { + const payload = JSON.stringify(draft, null, 2); + if (options.output) { + writeFileSync(resolve(cwd, options.output), payload, "utf8"); + console.log(pc.green(`Wrote JSON draft to ${options.output}`)); + } else { + console.log(payload); + } + return; + } + + if (options.output) { + writeFileSync(resolve(cwd, options.output), draft.markdown, "utf8"); + console.log(pc.green(`Wrote draft to ${options.output}`)); + } else { + console.log(""); + console.log(draft.markdown); + console.log(""); + console.log( + pc.dim( + `Suggested semver: ${draft.suggestedSemver} (heuristic: ${draft.heuristicSemver})`, + ), + ); + } + }); +} diff --git a/packages/cli/src/commands/validate-config.ts b/packages/cli/src/commands/validate-config.ts new file mode 100644 index 0000000..13834f3 --- /dev/null +++ b/packages/cli/src/commands/validate-config.ts @@ -0,0 +1,35 @@ +import { resolve } from "node:path"; +import type { Command } from "commander"; +import pc from "picocolors"; +import { findConfigPath, validateConfigFile } from "@polyscribe/core"; + +export function registerValidateConfigCommand(program: Command): void { + program + .command("validate-config") + .description("Validate .polyscribe.yml syntax and schema") + .argument("[path]", "config file path") + .action((pathArg?: string) => { + const cwd = process.cwd(); + const configPath = pathArg + ? resolve(cwd, pathArg) + : findConfigPath(cwd); + + if (!configPath) { + console.error( + pc.red("No config file found (.polyscribe.yml or .github/polyscribe.yml)"), + ); + process.exit(1); + } + + const result = validateConfigFile(configPath); + if (!result.ok) { + console.error(pc.red(`Invalid config: ${result.error}`)); + process.exit(1); + } + + console.log(pc.green(`Valid config: ${configPath}`)); + console.log( + pc.dim(`tone=${result.config.tone}, changelog=${result.config.changelogPath}`), + ); + }); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8b285a8..f9119f2 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -2,10 +2,46 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { Command } from "commander"; +import pc from "picocolors"; +import { registerChangelogCommand } from "./commands/changelog.js"; +import { registerConfigCommand } from "./commands/config.js"; +import { registerDoctorCommand } from "./commands/doctor.js"; +import { registerDraftCommand } from "./commands/draft.js"; +import { registerValidateConfigCommand } from "./commands/validate-config.js"; const packageRoot = dirname(fileURLToPath(import.meta.url)); const { version } = JSON.parse( readFileSync(join(packageRoot, "../package.json"), "utf8"), ) as { version: string }; -console.log(`polyscribe v${version}`); +export function createProgram(): Command { + const program = new Command(); + + program + .name("polyscribe") + .description("PolyScribe — evidence-linked release notes for GitHub") + .version(version); + + registerDraftCommand(program); + registerChangelogCommand(program); + registerConfigCommand(program); + registerValidateConfigCommand(program); + registerDoctorCommand(program); + + return program; +} + +export async function run(argv: string[] = process.argv): Promise { + const program = createProgram(); + + try { + await program.parseAsync(argv); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(pc.red(`Error: ${message}`)); + process.exit(1); + } +} + +void run(); diff --git a/packages/core/package.json b/packages/core/package.json index 0da1fcb..1a56cb4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -14,11 +14,20 @@ "dist" ], "scripts": { - "build": "tsup src/index.ts --format esm --dts --clean", - "test": "vitest run --passWithNoTests", + "build": "tsup", + "test": "vitest run", "lint": "eslint src", "typecheck": "tsc --noEmit" }, + "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", + "@octokit/rest": "^22.0.0", + "execa": "^9.6.0", + "minimatch": "^10.0.3", + "openai": "^4.77.0", + "yaml": "^2.8.0", + "zod": "^3.24.2" + }, "devDependencies": { "@types/node": "^22.15.30", "tsup": "^8.5.0", diff --git a/packages/core/src/changelog/index.ts b/packages/core/src/changelog/index.ts new file mode 100644 index 0000000..304d51c --- /dev/null +++ b/packages/core/src/changelog/index.ts @@ -0,0 +1,20 @@ +export { + parseChangelog, + type ChangelogVersion, + type ParsedChangelog, +} from "./parse.js"; + +export { insertVersion } from "./merge.js"; + +export function createEmptyChangelog(): string { + return `# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +`; +} diff --git a/packages/core/src/changelog/merge.ts b/packages/core/src/changelog/merge.ts new file mode 100644 index 0000000..0e24fe3 --- /dev/null +++ b/packages/core/src/changelog/merge.ts @@ -0,0 +1,38 @@ +import { parseChangelog } from "./parse.js"; + +function formatVersionHeading(version: string, date?: string): string { + return date ? `## [${version}] - ${date}` : `## [${version}]`; +} + +function normalizeContent(content: string): string { + const trimmed = content.trim(); + return trimmed.length > 0 ? `\n${trimmed}\n` : "\n"; +} + +export function insertVersion( + changelog: string, + version: string, + date: string, + content: string, +): string { + const parsed = parseChangelog(changelog); + const blocks: string[] = []; + + if (parsed.preamble.length > 0) { + blocks.push(parsed.preamble); + } + + if (parsed.unreleased) { + blocks.push(`## [Unreleased]\n${parsed.unreleased.content.trimEnd()}`); + } + + blocks.push(`${formatVersionHeading(version, date)}${normalizeContent(content)}`); + + for (const existing of parsed.versions) { + blocks.push( + `${formatVersionHeading(existing.version, existing.date)}${normalizeContent(existing.content)}`, + ); + } + + return `${blocks.join("\n\n").replace(/\n{3,}/g, "\n\n")}\n`; +} diff --git a/packages/core/src/changelog/parse.ts b/packages/core/src/changelog/parse.ts new file mode 100644 index 0000000..de90dc0 --- /dev/null +++ b/packages/core/src/changelog/parse.ts @@ -0,0 +1,79 @@ +export interface ChangelogVersion { + version: string; + date?: string; + content: string; + startLine: number; + endLine: number; +} + +export interface ParsedChangelog { + preamble: string; + unreleased?: ChangelogVersion; + versions: ChangelogVersion[]; +} + +const VERSION_HEADING = + /^##\s+\[(?[^\]]+)\](?:\s+-\s+(?\d{4}-\d{2}-\d{2}))?\s*$/; + +function parseVersionHeading(line: string): { version: string; date?: string } | null { + const match = line.match(VERSION_HEADING); + if (!match?.groups?.version) { + return null; + } + + return { + version: match.groups.version.trim(), + date: match.groups.date?.trim(), + }; +} + +export function parseChangelog(markdown: string): ParsedChangelog { + const lines = markdown.split(/\r?\n/); + const preambleLines: string[] = []; + const sections: ChangelogVersion[] = []; + + let index = 0; + while (index < lines.length) { + const parsed = parseVersionHeading(lines[index] ?? ""); + if (parsed) break; + preambleLines.push(lines[index] ?? ""); + index += 1; + } + + while (index < lines.length) { + const headingLine = lines[index] ?? ""; + const parsed = parseVersionHeading(headingLine); + if (!parsed) { + index += 1; + continue; + } + + const startLine = index; + index += 1; + + const contentLines: string[] = []; + while (index < lines.length) { + const next = lines[index] ?? ""; + if (parseVersionHeading(next)) break; + contentLines.push(next); + index += 1; + } + + sections.push({ + version: parsed.version, + date: parsed.date, + content: contentLines.join("\n").trimEnd(), + startLine, + endLine: index - 1, + }); + } + + const unreleased = sections.find((s) => s.version === "Unreleased"); + const versions = sections.filter((s) => s.version !== "Unreleased"); + + return { + preamble: preambleLines.join("\n").trimEnd(), + unreleased, + versions, + }; +} diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts new file mode 100644 index 0000000..f48cad4 --- /dev/null +++ b/packages/core/src/config/index.ts @@ -0,0 +1,18 @@ +export { + DEFAULT_IGNORE_GLOBS, + DEFAULT_SECTION_ORDER, + defaultConfig, + draftSectionTypeSchema, + EXAMPLE_CONFIG_YAML, + parseConfig, + polyScribeConfigSchema, + type PolyScribeConfigFile, + type PolyScribeConfigInput, +} from "./schema.js"; + +export { + findConfigPath, + loadConfig, + validateConfigFile, + type LoadConfigResult, +} from "./load.js"; diff --git a/packages/core/src/config/load.ts b/packages/core/src/config/load.ts new file mode 100644 index 0000000..c346fa1 --- /dev/null +++ b/packages/core/src/config/load.ts @@ -0,0 +1,106 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { parse as parseYaml } from "yaml"; +import { + defaultConfig, + parseConfig, + type PolyScribeConfigFile, +} from "./schema.js"; + +const CONFIG_FILENAMES = [ + ".github/polyscribe.yml", + ".polyscribe.yml", +] as const; + +function readYamlFile(path: string): unknown { + const raw = readFileSync(path, "utf8"); + const parsed = parseYaml(raw); + if (parsed === null || parsed === undefined) { + return {}; + } + if (typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Invalid config at ${path}: expected a YAML mapping`); + } + return parsed; +} + +function deepMerge( + base: Record, + overlay: Record, +): Record { + const result: Record = { ...base }; + + for (const [key, value] of Object.entries(overlay)) { + const existing = result[key]; + if ( + value !== null && + typeof value === "object" && + !Array.isArray(value) && + existing !== null && + typeof existing === "object" && + !Array.isArray(existing) + ) { + result[key] = deepMerge( + existing as Record, + value as Record, + ); + } else { + result[key] = value; + } + } + + return result; +} + +export interface LoadConfigResult { + config: PolyScribeConfigFile; + path?: string; +} + +export function loadConfig(cwd: string): LoadConfigResult { + let merged: Record = {}; + let loadedPath: string | undefined; + + for (const relativePath of CONFIG_FILENAMES) { + const absolutePath = join(cwd, relativePath); + if (!existsSync(absolutePath)) { + continue; + } + + const fileConfig = readYamlFile(absolutePath) as Record; + merged = deepMerge(merged, fileConfig); + loadedPath = absolutePath; + } + + if (Object.keys(merged).length === 0) { + return { config: defaultConfig() }; + } + + return { + config: parseConfig(merged), + path: loadedPath, + }; +} + +export function findConfigPath(cwd: string): string | undefined { + for (const relativePath of [...CONFIG_FILENAMES].reverse()) { + const absolutePath = join(cwd, relativePath); + if (existsSync(absolutePath)) { + return absolutePath; + } + } + return undefined; +} + +export function validateConfigFile( + configPath: string, +): { ok: true; config: PolyScribeConfigFile } | { ok: false; error: string } { + try { + const fileConfig = readYamlFile(configPath) as Record; + const config = parseConfig(fileConfig); + return { ok: true, config }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, error: message }; + } +} diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts new file mode 100644 index 0000000..9e308e4 --- /dev/null +++ b/packages/core/src/config/schema.ts @@ -0,0 +1,107 @@ +import { z } from "zod"; + +export const DEFAULT_IGNORE_GLOBS = [ + "**/package-lock.json", + "**/pnpm-lock.yaml", + "**/yarn.lock", + "**/bun.lockb", + "**/dist/**", + "**/generated/**", +] as const; + +export const DEFAULT_SECTION_ORDER = [ + "summary", + "breaking", + "features", + "fixes", + "perf", + "security", + "docs", + "chore", + "migration", + "credits", +] as const; + +export const draftSectionTypeSchema = z.enum([ + "summary", + "breaking", + "features", + "fixes", + "perf", + "docs", + "chore", + "security", + "migration", + "credits", +]); + +const toneSchema = z.enum([ + "technical", + "developer-friendly", + "executive", + "community", +]); + +const publishTargetSchema = z.enum(["github-release", "changelog-pr"]); + +const llmSchema = z + .object({ + provider: z.enum(["openai", "anthropic", "openai-compatible"]), + model: z.string().min(1), + }) + .optional(); + +const sectionsSchema = z + .object({ + order: z.array(draftSectionTypeSchema).optional(), + }) + .optional(); + +export const polyScribeConfigSchema = z.object({ + changelogPath: z.string().default("CHANGELOG.md"), + tone: toneSchema.default("developer-friendly"), + ignoreGlobs: z.array(z.string()).default([...DEFAULT_IGNORE_GLOBS]), + monorepoRoots: z.array(z.string()).default([]), + requireApprover: z.boolean().default(true), + autoPublish: z.boolean().default(false), + includeUnreleased: z.boolean().default(false), + publishTargets: z + .array(publishTargetSchema) + .default(["github-release", "changelog-pr"]), + sections: sectionsSchema, + includeCommittersWithoutPr: z.boolean().default(true), + maxDiffBytesPerFile: z.number().int().positive().default(20_000), + maxTotalDiffBytes: z.number().int().positive().default(400_000), + llm: llmSchema, +}); + +export type PolyScribeConfigInput = z.input; +export type PolyScribeConfigFile = z.infer; + +export function defaultConfig(): PolyScribeConfigFile { + return polyScribeConfigSchema.parse({}); +} + +export function parseConfig(input: unknown): PolyScribeConfigFile { + return polyScribeConfigSchema.parse(input); +} + +export const EXAMPLE_CONFIG_YAML = `# PolyScribe configuration +changelogPath: CHANGELOG.md +tone: developer-friendly +requireApprover: true +autoPublish: false +includeUnreleased: false +publishTargets: + - github-release + - changelog-pr +ignoreGlobs: + - "**/package-lock.json" + - "**/pnpm-lock.yaml" + - "**/dist/**" +maxDiffBytesPerFile: 20000 +maxTotalDiffBytes: 400000 +llm: + provider: openai + model: gpt-4.1 +`; diff --git a/packages/core/src/draft/generate.ts b/packages/core/src/draft/generate.ts new file mode 100644 index 0000000..39be742 --- /dev/null +++ b/packages/core/src/draft/generate.ts @@ -0,0 +1,152 @@ +import { randomUUID } from "node:crypto"; +import type { + Draft, + DraftSection, + GenerateDraftConfig, + GitHubUser, + SourceItem, +} from "../types.js"; +import { suggestSemverFromSources } from "../semver/heuristics.js"; +import { buildDraftPrompts } from "./prompt.js"; +import { llmDraftOutputSchema } from "./schema.js"; +import { renderDraftMarkdown } from "./render.js"; +import type { LLMClient } from "./llm/client.js"; + +export class CitationValidationError extends Error { + constructor( + message: string, + readonly invalidSections: Array<{ type: string; reason: string }>, + ) { + super(message); + this.name = "CitationValidationError"; + } +} + +export function validateSectionCitations( + sections: DraftSection[], + sourceIds: Set, +): Array<{ type: string; reason: string }> { + const invalid: Array<{ type: string; reason: string }> = []; + + for (const section of sections) { + if (section.type === "credits") continue; + if (!section.content.trim()) continue; + + const bullets = section.content + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("-") || line.startsWith("*")); + + if (bullets.length === 0) { + if (section.sourceIds.length === 0) { + invalid.push({ + type: section.type, + reason: "non-empty section missing sourceIds", + }); + } + continue; + } + + for (const [index, bullet] of bullets.entries()) { + const hasInlineCitation = /\(.*?#?\d+.*?\)|\bsource:/i.test(bullet); + const hasSectionSources = section.sourceIds.some((id) => sourceIds.has(id)); + + if (!hasInlineCitation && !hasSectionSources) { + invalid.push({ + type: section.type, + reason: `bullet ${index + 1} lacks valid source citation`, + }); + } + } + + for (const id of section.sourceIds) { + if (!sourceIds.has(id)) { + invalid.push({ + type: section.type, + reason: `unknown sourceId: ${id}`, + }); + } + } + } + + return invalid; +} + +function uniqueContributors( + fromLlm: Array<{ login: string; id?: string }>, + sources: SourceItem[], +): GitHubUser[] { + const map = new Map(); + + for (const source of sources) { + map.set(source.author.login, source.author); + } + + for (const contributor of fromLlm) { + map.set(contributor.login, { + login: contributor.login, + id: contributor.id ?? contributor.login, + }); + } + + return [...map.values()]; +} + +export async function generateDraft( + sources: SourceItem[], + config: GenerateDraftConfig, + llmClient: LLMClient, +): Promise { + if (sources.length === 0) { + throw new Error("Cannot generate draft: no sources in range"); + } + + const tone = config.tone ?? "developer-friendly"; + const heuristicSemver = suggestSemverFromSources(sources); + const prompts = buildDraftPrompts(sources, tone, heuristicSemver); + const sourceIdSet = new Set(sources.map((s) => s.id)); + + const output = await llmClient.completeStructured({ + system: prompts.system, + user: prompts.user, + schema: llmDraftOutputSchema, + maxTokens: 4096, + }); + + const sections: DraftSection[] = output.sections.map((section) => ({ + type: section.type, + title: section.title, + content: section.content, + sourceIds: section.sourceIds ?? [], + })); + + const invalid = validateSectionCitations(sections, sourceIdSet); + if (invalid.length > 0) { + throw new CitationValidationError( + `Draft failed citation validation (${invalid.length} issue(s))`, + invalid, + ); + } + + const now = new Date().toISOString(); + const contributors = uniqueContributors(output.contributors ?? [], sources); + const markdown = renderDraftMarkdown(sections); + + return { + id: randomUUID(), + repositoryId: config.repositoryId ?? "local", + range: config.range ?? { fromRef: "unknown", toRef: "unknown" }, + status: "pending", + suggestedSemver: output.suggestedSemver, + heuristicSemver, + markdown, + sections, + contributors, + model: { + provider: llmClient.provider, + name: llmClient.model, + }, + createdAt: now, + updatedAt: now, + }; +} diff --git a/packages/core/src/draft/index.ts b/packages/core/src/draft/index.ts new file mode 100644 index 0000000..07b60bc --- /dev/null +++ b/packages/core/src/draft/index.ts @@ -0,0 +1,24 @@ +export { + buildDraftPrompts, + buildDraftSystemPrompt, + buildDraftUserPrompt, + formatSourceForPrompt, +} from "./prompt.js"; +export type { DraftPrompts } from "./prompt.js"; +export { llmDraftOutputSchema, llmDraftSectionSchema } from "./schema.js"; +export type { LLMDraftOutput, LLMDraftSection } from "./schema.js"; +export { + generateDraft, + validateSectionCitations, + CitationValidationError, +} from "./generate.js"; +export { + renderDraftMarkdown, + renderKeepAChangelogBody, + getSectionTitle, + sortSections, +} from "./render.js"; +export type { LLMClient, LLMCompleteInput } from "./llm/client.js"; +export { createLLMClient, hasLLMCredentials } from "./llm/factory.js"; +export type { CreateLLMClientOptions } from "./llm/factory.js"; +export { OpenAILLMClient, AnthropicLLMClient } from "./llm/providers.js"; diff --git a/packages/core/src/draft/llm/client.ts b/packages/core/src/draft/llm/client.ts new file mode 100644 index 0000000..326395d --- /dev/null +++ b/packages/core/src/draft/llm/client.ts @@ -0,0 +1,14 @@ +import type { ZodSchema } from "zod"; + +export interface LLMCompleteInput { + system: string; + user: string; + schema: ZodSchema; + maxTokens?: number; +} + +export interface LLMClient { + readonly provider: string; + readonly model: string; + completeStructured(input: LLMCompleteInput): Promise; +} diff --git a/packages/core/src/draft/llm/factory.ts b/packages/core/src/draft/llm/factory.ts new file mode 100644 index 0000000..be4296a --- /dev/null +++ b/packages/core/src/draft/llm/factory.ts @@ -0,0 +1,70 @@ +import type { LLMProvider } from "../../types.js"; +import { AnthropicLLMClient, OpenAILLMClient } from "./providers.js"; + +export interface CreateLLMClientOptions { + provider?: LLMProvider; + model?: string; + openaiApiKey?: string; + anthropicApiKey?: string; + baseUrl?: string; +} + +function resolveProvider(explicit?: LLMProvider): LLMProvider { + if (explicit) return explicit; + + const envProvider = process.env.POLYSCRIBE_LLM_PROVIDER as LLMProvider | undefined; + if (envProvider) return envProvider; + + if (process.env.ANTHROPIC_API_KEY && !process.env.OPENAI_API_KEY) { + return "anthropic"; + } + + return "openai"; +} + +function defaultModel(provider: LLMProvider): string { + switch (provider) { + case "anthropic": + return "claude-sonnet-4-20250514"; + case "openai": + case "openai-compatible": + return "gpt-4.1"; + } +} + +export function createLLMClient(options: CreateLLMClientOptions = {}) { + const provider = resolveProvider(options.provider); + const model = options.model ?? defaultModel(provider); + + switch (provider) { + case "anthropic": { + const apiKey = options.anthropicApiKey ?? process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + throw new Error("ANTHROPIC_API_KEY is required for anthropic provider"); + } + return new AnthropicLLMClient(model, apiKey); + } + case "openai": + case "openai-compatible": { + const apiKey = options.openaiApiKey ?? process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error("OPENAI_API_KEY is required for openai provider"); + } + return new OpenAILLMClient(model, apiKey, options.baseUrl); + } + default: { + const _exhaustive: never = provider; + throw new Error(`Unsupported LLM provider: ${String(_exhaustive)}`); + } + } +} + +export function hasLLMCredentials(provider?: LLMProvider): boolean { + const resolved = resolveProvider(provider); + if (resolved === "anthropic") { + return Boolean(process.env.ANTHROPIC_API_KEY); + } + return Boolean(process.env.OPENAI_API_KEY); +} + +export type { LLMClient } from "./client.js"; diff --git a/packages/core/src/draft/llm/providers.ts b/packages/core/src/draft/llm/providers.ts new file mode 100644 index 0000000..5b638e0 --- /dev/null +++ b/packages/core/src/draft/llm/providers.ts @@ -0,0 +1,90 @@ +import OpenAI from "openai"; +import Anthropic from "@anthropic-ai/sdk"; +import type { ZodSchema } from "zod"; +import { zodToJsonSchema } from "../zod-to-json-schema.js"; +import type { LLMClient, LLMCompleteInput } from "./client.js"; + +const DEFAULT_MAX_TOKENS = 4096; + +export class OpenAILLMClient implements LLMClient { + readonly provider = "openai"; + private readonly client: OpenAI; + + constructor( + readonly model: string, + apiKey?: string, + baseURL?: string, + ) { + this.client = new OpenAI({ + apiKey: apiKey ?? process.env.OPENAI_API_KEY, + baseURL, + }); + } + + async completeStructured(input: LLMCompleteInput): Promise { + const jsonSchema = zodToJsonSchema(input.schema); + const response = await this.client.chat.completions.create({ + model: this.model, + max_tokens: input.maxTokens ?? DEFAULT_MAX_TOKENS, + response_format: { + type: "json_schema", + json_schema: { + name: "polyscribe_draft", + strict: true, + schema: jsonSchema, + }, + }, + messages: [ + { role: "system", content: input.system }, + { role: "user", content: input.user }, + ], + }); + + const content = response.choices[0]?.message?.content; + if (!content) { + throw new Error("OpenAI returned empty response"); + } + + const parsed = JSON.parse(content) as unknown; + return input.schema.parse(parsed); + } +} + +export class AnthropicLLMClient implements LLMClient { + readonly provider = "anthropic"; + private readonly client: Anthropic; + + constructor( + readonly model: string, + apiKey?: string, + ) { + this.client = new Anthropic({ + apiKey: apiKey ?? process.env.ANTHROPIC_API_KEY, + }); + } + + async completeStructured(input: LLMCompleteInput): Promise { + const jsonSchema = zodToJsonSchema(input.schema); + const response = await this.client.messages.create({ + model: this.model, + max_tokens: input.maxTokens ?? DEFAULT_MAX_TOKENS, + system: input.system, + messages: [{ role: "user", content: input.user }], + tools: [ + { + name: "polyscribe_draft", + description: "Structured release notes draft", + input_schema: jsonSchema as Anthropic.Tool.InputSchema, + }, + ], + tool_choice: { type: "tool", name: "polyscribe_draft" }, + }); + + const toolBlock = response.content.find((block) => block.type === "tool_use"); + if (!toolBlock || toolBlock.type !== "tool_use") { + throw new Error("Anthropic returned no tool_use block"); + } + + return input.schema.parse(toolBlock.input); + } +} diff --git a/packages/core/src/draft/prompt.ts b/packages/core/src/draft/prompt.ts new file mode 100644 index 0000000..7ab5ad6 --- /dev/null +++ b/packages/core/src/draft/prompt.ts @@ -0,0 +1,106 @@ +import type { SourceItem, Tone } from "../types.js"; + +const TONE_GUIDANCE: Record = { + technical: + "Write precise, API-oriented release notes. Prefer concrete identifiers, endpoints, and behavioral changes.", + "developer-friendly": + "Write clear, friendly, concrete release notes suitable for most open-source and product API audiences.", + executive: + "Write short, outcome-focused release notes emphasizing user and business impact over implementation detail.", + community: + "Write warm, credits-forward release notes that celebrate contributors and community impact.", +}; + +const SECTION_GUIDANCE = `Produce structured release note sections. Omit empty sections except Summary and Contributors when sources exist. + +Section types: +- summary: high-level overview of the release +- breaking: breaking changes requiring user action +- features: new capabilities +- fixes: bug fixes +- perf: performance improvements +- security: security fixes or hardening +- docs: documentation changes +- chore: maintenance and internal changes +- migration: migration steps for breaking or behavioral changes +- credits: contributor acknowledgements + +Every bullet in content must cite at least one source id from the provided list in sourceIds. +Use markdown bullets in content. Reference PR numbers or authors when available.`; + +export interface DraftPrompts { + system: string; + user: string; +} + +export function buildDraftSystemPrompt(tone: Tone): string { + return `You are PolyScribe, an expert release notes editor for GitHub repositories. + +${TONE_GUIDANCE[tone]} + +${SECTION_GUIDANCE} + +Rules: +- Only describe changes supported by the provided sources. +- Every section bullet must map to one or more sourceIds. +- Do not invent features, APIs, or contributors. +- Suggested semver must be patch, minor, or major. +- Respond with valid JSON matching the required schema.`; +} + +export function formatSourceForPrompt(source: SourceItem): string { + const lines = [ + `id: ${source.id}`, + `type: ${source.type}`, + `title: ${source.title}`, + `author: ${source.author.login}`, + `url: ${source.url}`, + ]; + + if (source.sha) lines.push(`sha: ${source.sha}`); + if (source.prNumber) lines.push(`pr: #${source.prNumber}`); + if (source.labels.length) lines.push(`labels: ${source.labels.join(", ")}`); + if (source.body) lines.push(`body:\n${source.body.trim()}`); + if (source.linkedIssues?.length) { + lines.push( + `linked_issues: ${source.linkedIssues.map((i) => `#${i.number} ${i.title}`).join("; ")}`, + ); + } + if (source.files?.length) { + lines.push( + `files: ${source.files.map((f) => `${f.status} ${f.path} (+${f.additions}/-${f.deletions})`).join("; ")}`, + ); + } + + return lines.join("\n"); +} + +export function buildDraftUserPrompt( + sources: SourceItem[], + tone: Tone, + heuristicSemver: string, +): string { + const sourceBlock = + sources.length === 0 + ? "No sources in range." + : sources.map(formatSourceForPrompt).join("\n\n---\n\n"); + + return `Tone: ${tone} +Heuristic semver suggestion: ${heuristicSemver} + +Sources (${sources.length}): +${sourceBlock} + +Draft release notes for this range. Include Summary and Contributors when sources exist.`; +} + +export function buildDraftPrompts( + sources: SourceItem[], + tone: Tone, + heuristicSemver: string, +): DraftPrompts { + return { + system: buildDraftSystemPrompt(tone), + user: buildDraftUserPrompt(sources, tone, heuristicSemver), + }; +} diff --git a/packages/core/src/draft/render.ts b/packages/core/src/draft/render.ts new file mode 100644 index 0000000..79434cf --- /dev/null +++ b/packages/core/src/draft/render.ts @@ -0,0 +1,111 @@ +import type { DraftSection, DraftSectionType } from "../types.js"; + +const SECTION_TITLES: Record = { + summary: "Summary", + breaking: "Breaking Changes", + features: "Features", + fixes: "Fixes", + perf: "Performance", + security: "Security", + docs: "Documentation", + chore: "Maintenance", + migration: "Migration Guide", + credits: "Contributors", +}; + +const DEFAULT_SECTION_ORDER: DraftSectionType[] = [ + "summary", + "breaking", + "features", + "fixes", + "perf", + "security", + "docs", + "chore", + "migration", + "credits", +]; + +export function getSectionTitle(type: DraftSectionType): string { + return SECTION_TITLES[type]; +} + +export function sortSections( + sections: DraftSection[], + order: DraftSectionType[] = DEFAULT_SECTION_ORDER, +): DraftSection[] { + const rank = new Map(order.map((type, index) => [type, index])); + return [...sections].sort( + (a, b) => (rank.get(a.type) ?? 999) - (rank.get(b.type) ?? 999), + ); +} + +export function renderDraftMarkdown( + sections: DraftSection[], + order?: DraftSectionType[], +): string { + const sorted = sortSections(sections, order); + const rendered = sorted + .filter((section) => section.content.trim().length > 0) + .map((section) => `## ${section.title}\n${section.content.trim()}`) + .join("\n\n"); + + return rendered.trim(); +} + +export function renderKeepAChangelogBody( + sections: DraftSection[], + order?: DraftSectionType[], +): string { + const changelogTypes: DraftSectionType[] = [ + "breaking", + "features", + "fixes", + "perf", + "security", + "docs", + "chore", + "migration", + ]; + + const sorted = sortSections(sections, order); + const parts: string[] = []; + + for (const type of changelogTypes) { + const section = sorted.find((s) => s.type === type); + if (!section?.content.trim()) continue; + + const keepTitle = mapToKeepAChangelogHeading(type); + parts.push(`### ${keepTitle}\n\n${section.content.trim()}`); + } + + const summary = sorted.find((s) => s.type === "summary"); + if (summary?.content.trim()) { + parts.unshift(summary.content.trim()); + } + + return parts.join("\n\n").trim(); +} + +function mapToKeepAChangelogHeading(type: DraftSectionType): string { + switch (type) { + case "breaking": + return "Breaking Changes"; + case "features": + return "Added"; + case "fixes": + return "Fixed"; + case "perf": + return "Performance"; + case "security": + return "Security"; + case "docs": + return "Documentation"; + case "chore": + return "Changed"; + case "migration": + return "Migration"; + default: + return getSectionTitle(type); + } +} diff --git a/packages/core/src/draft/schema.ts b/packages/core/src/draft/schema.ts new file mode 100644 index 0000000..6d984e7 --- /dev/null +++ b/packages/core/src/draft/schema.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; +import { draftSectionTypeSchema } from "../config/schema.js"; + +export const llmDraftSectionSchema = z.object({ + type: draftSectionTypeSchema, + title: z.string().min(1), + content: z.string(), + sourceIds: z.array(z.string()).default([]), +}); + +export const llmDraftOutputSchema = z.object({ + suggestedSemver: z.enum(["patch", "minor", "major"]), + sections: z.array(llmDraftSectionSchema).min(1), + contributors: z + .array( + z.object({ + login: z.string(), + id: z.string().optional(), + }), + ) + .default([]), +}); + +export type LLMDraftOutput = z.infer; +export type LLMDraftSection = z.infer; diff --git a/packages/core/src/draft/zod-to-json-schema.ts b/packages/core/src/draft/zod-to-json-schema.ts new file mode 100644 index 0000000..1b70093 --- /dev/null +++ b/packages/core/src/draft/zod-to-json-schema.ts @@ -0,0 +1,72 @@ +import type { ZodSchema } from "zod"; + +type JsonSchema = Record; + +type ZodDef = { + typeName?: string; + shape?: () => Record>; + values?: readonly string[]; + type?: ZodSchema; + innerType?: ZodSchema; + options?: ZodSchema[]; +}; + +function getDef(schema: ZodSchema): ZodDef { + return (schema as { _def: ZodDef })._def; +} + +export function zodToJsonSchema(schema: ZodSchema): JsonSchema { + const def = getDef(schema as ZodSchema); + + switch (def.typeName) { + case "ZodObject": { + const shape = def.shape?.() ?? {}; + const properties: Record = {}; + const required: string[] = []; + + for (const [key, value] of Object.entries(shape)) { + properties[key] = zodToJsonSchema(value); + const inner = getDef(value); + if (inner.typeName !== "ZodDefault" && inner.typeName !== "ZodOptional") { + required.push(key); + } + } + + return { + type: "object", + properties, + required, + additionalProperties: false, + }; + } + case "ZodString": + return { type: "string" }; + case "ZodNumber": + return { type: "number" }; + case "ZodBoolean": + return { type: "boolean" }; + case "ZodArray": + return { + type: "array", + items: def.type ? zodToJsonSchema(def.type) : {}, + }; + case "ZodEnum": + return { type: "string", enum: [...(def.values ?? [])] }; + case "ZodDefault": + case "ZodOptional": + return def.innerType ? zodToJsonSchema(def.innerType) : {}; + case "ZodUnion": { + const options = (def.options ?? []).map((option) => zodToJsonSchema(option)); + const enums = options + .map((option) => option.enum) + .filter((value): value is string[] => Array.isArray(value)) + .flat(); + if (enums.length === options.length && options.length > 0) { + return { type: "string", enum: [...new Set(enums)] }; + } + return { anyOf: options }; + } + default: + return {}; + } +} diff --git a/packages/core/src/git/diff.ts b/packages/core/src/git/diff.ts new file mode 100644 index 0000000..3efc981 --- /dev/null +++ b/packages/core/src/git/diff.ts @@ -0,0 +1,69 @@ +import type { FileChange } from "../types.js"; +import { shouldIgnorePath } from "../ingest/filter.js"; +import { git } from "./exec.js"; + +function parseNumstatLine(line: string): { + path: string; + additions: number; + deletions: number; +} | null { + const parts = line.split("\t"); + if (parts.length < 3) return null; + + const [addRaw, delRaw, path] = parts; + if (!path || path === "-") return null; + + return { + path, + additions: Number(addRaw) || 0, + deletions: Number(delRaw) || 0, + }; +} + +export async function getFileChanges( + cwd: string, + fromSha: string, + toSha: string, + options: { + ignoreGlobs: string[]; + maxDiffBytesPerFile: number; + maxTotalDiffBytes: number; + }, +): Promise { + const result = await git(cwd, ["diff", "--numstat", `${fromSha}..${toSha}`]); + + const changes: FileChange[] = []; + let totalBytes = 0; + + for (const line of result.stdout.split("\n")) { + const stat = parseNumstatLine(line.trim()); + if (!stat) continue; + if (shouldIgnorePath(stat.path, options.ignoreGlobs)) continue; + + const patchResult = await git(cwd, [ + "diff", + `${fromSha}..${toSha}`, + "--", + stat.path, + ]); + let patch = patchResult.stdout; + if (patch.length > options.maxDiffBytesPerFile) { + patch = `${patch.slice(0, options.maxDiffBytesPerFile)}\n… [truncated]`; + } + + totalBytes += patch.length; + if (totalBytes > options.maxTotalDiffBytes) { + break; + } + + changes.push({ + path: stat.path, + status: "modified", + additions: stat.additions, + deletions: stat.deletions, + patch: patch || undefined, + }); + } + + return changes; +} diff --git a/packages/core/src/git/exec.ts b/packages/core/src/git/exec.ts new file mode 100644 index 0000000..5045126 --- /dev/null +++ b/packages/core/src/git/exec.ts @@ -0,0 +1,24 @@ +import { execa } from "execa"; + +export async function git( + cwd: string, + args: string[], +): Promise<{ stdout: string; stderr: string }> { + const result = await execa("git", args, { cwd }); + return { stdout: result.stdout, stderr: result.stderr }; +} + +export async function tryGit( + cwd: string, + args: string[], +): Promise<{ ok: true; stdout: string } | { ok: false; error: Error }> { + try { + const result = await git(cwd, args); + return { ok: true, stdout: result.stdout }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error : new Error(String(error)), + }; + } +} diff --git a/packages/core/src/git/log.ts b/packages/core/src/git/log.ts new file mode 100644 index 0000000..bd76d55 --- /dev/null +++ b/packages/core/src/git/log.ts @@ -0,0 +1,31 @@ +import type { CommitInfo } from "../types.js"; +import { git } from "./exec.js"; + +export async function getCommitsInRange( + cwd: string, + fromSha: string, + toSha: string, +): Promise { + const format = "%H%x1f%an%x1f%ae%x1f%aI%x1f%s%x1f%b"; + const result = await git(cwd, [ + "log", + `${fromSha}..${toSha}`, + "--no-merges", + `--format=${format}`, + ]); + + if (!result.stdout.trim()) { + return []; + } + + return result.stdout.split("\n").map((line) => { + const [sha, login, email, committedAt, title, body] = line.split("\x1f"); + return { + sha: sha!, + title: title!, + body: body || undefined, + author: { login: login || "unknown", id: email || sha!.slice(0, 7) }, + committedAt: committedAt!, + }; + }); +} diff --git a/packages/core/src/git/refs.ts b/packages/core/src/git/refs.ts new file mode 100644 index 0000000..92f598b --- /dev/null +++ b/packages/core/src/git/refs.ts @@ -0,0 +1,40 @@ +import type { ReleaseRange, ResolvedRange } from "../types.js"; +import { git, tryGit } from "./exec.js"; + +export async function resolveRef(cwd: string, ref: string): Promise { + const result = await git(cwd, ["rev-parse", ref]); + return result.stdout.trim(); +} + +export async function getLatestTag(cwd: string): Promise { + const result = await tryGit(cwd, ["describe", "--tags", "--abbrev=0"]); + return result.ok ? result.stdout.trim() : null; +} + +export async function resolveRange( + cwd: string, + fromRef?: string, + toRef = "HEAD", +): Promise { + const toSha = await resolveRef(cwd, toRef); + + if (fromRef) { + const fromSha = await resolveRef(cwd, fromRef); + return { fromRef, toRef, fromSha, toSha }; + } + + const latestTag = await getLatestTag(cwd); + if (latestTag) { + const fromSha = await resolveRef(cwd, latestTag); + return { fromRef: latestTag, toRef, fromSha, toSha }; + } + + const rootResult = await tryGit(cwd, ["rev-list", "--max-parents=0", "HEAD"]); + const fromSha = rootResult.ok + ? rootResult.stdout.split("\n")[0]!.trim() + : toSha; + + return { fromRef: fromSha, toRef, fromSha, toSha }; +} + +export type { ReleaseRange, ResolvedRange }; diff --git a/packages/core/src/github/client.ts b/packages/core/src/github/client.ts new file mode 100644 index 0000000..eb48efd --- /dev/null +++ b/packages/core/src/github/client.ts @@ -0,0 +1,43 @@ +import type { RemoteRepo } from "../types.js"; +import { tryGit } from "../git/exec.js"; + +export function parseRemoteUrl(url: string): RemoteRepo | null { + const sshMatch = url.match(/^git@([^:]+):([^/]+)\/(.+?)(?:\.git)?$/); + if (sshMatch) { + return { + host: sshMatch[1]!, + owner: sshMatch[2]!, + repo: sshMatch[3]!.replace(/\.git$/, ""), + }; + } + + const httpsMatch = url.match(/^https?:\/\/([^/]+)\/([^/]+)\/(.+?)(?:\.git)?$/); + if (httpsMatch) { + return { + host: httpsMatch[1]!, + owner: httpsMatch[2]!, + repo: httpsMatch[3]!.replace(/\.git$/, ""), + }; + } + + return null; +} + +export async function detectRemoteRepo(cwd: string): Promise { + const result = await tryGit(cwd, ["remote", "get-url", "origin"]); + if (!result.ok) return null; + return parseRemoteUrl(result.stdout.trim()); +} + +export async function getCommitDate( + cwd: string, + sha: string, +): Promise { + const result = await tryGit(cwd, ["show", "-s", "--format=%aI", sha]); + return result.ok ? result.stdout.trim() : null; +} + +export function createOctokit(token?: string) { + if (!token) return null; + return { auth: token }; +} diff --git a/packages/core/src/github/prs.ts b/packages/core/src/github/prs.ts new file mode 100644 index 0000000..e3f8ddf --- /dev/null +++ b/packages/core/src/github/prs.ts @@ -0,0 +1,79 @@ +import { Octokit } from "@octokit/rest"; +import type { RemoteRepo, SourceItem } from "../types.js"; + +export function mapPrToSourceItem( + pr: { + number: number; + title: string; + body?: string | null; + user?: { login?: string | null; id?: number | null } | null; + merged_at?: string | null; + merge_commit_sha?: string | null; + labels?: Array<{ name?: string | null } | string>; + html_url?: string; + }, + remote?: RemoteRepo | null, +): SourceItem { + const labels = (pr.labels ?? []) + .map((label) => (typeof label === "string" ? label : (label.name ?? ""))) + .filter(Boolean); + + return { + id: `pr:${pr.number}`, + type: "pr", + prNumber: pr.number, + sha: pr.merge_commit_sha ?? undefined, + title: pr.title, + body: pr.body ?? undefined, + author: { + login: pr.user?.login ?? "unknown", + id: String(pr.user?.id ?? pr.number), + }, + mergedAt: pr.merged_at ?? undefined, + labels, + url: + pr.html_url ?? + (remote + ? `https://${remote.host}/${remote.owner}/${remote.repo}/pull/${pr.number}` + : `pr:${pr.number}`), + }; +} + +export async function fetchMergedPrsInRange( + remote: RemoteRepo, + token: string | undefined, + since: string, + until: string, +): Promise { + if (!token || remote.host !== "github.com") { + return []; + } + + const octokit = new Octokit({ auth: token }); + const items: SourceItem[] = []; + let page = 1; + + while (page <= 5) { + const response = await octokit.pulls.list({ + owner: remote.owner, + repo: remote.repo, + state: "closed", + sort: "updated", + direction: "desc", + per_page: 100, + page, + }); + + if (response.data.length === 0) break; + + for (const pr of response.data) { + if (!pr.merged_at) continue; + if (pr.merged_at < since || pr.merged_at > until) continue; + items.push(mapPrToSourceItem(pr, remote)); + } + + page += 1; + } + + return items; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4fecd09..3fa2ce1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,7 @@ -// Placeholder — core exports will be added in a follow-up. - -export {}; +export * from "./types.js"; +export * from "./config/index.js"; +export * from "./sources/index.js"; +export * from "./semver/index.js"; +export * from "./changelog/index.js"; +export * from "./draft/index.js"; +export * from "./redact/index.js"; diff --git a/packages/core/src/ingest/collect.ts b/packages/core/src/ingest/collect.ts new file mode 100644 index 0000000..142a9fc --- /dev/null +++ b/packages/core/src/ingest/collect.ts @@ -0,0 +1,91 @@ +import { getFileChanges } from "../git/diff.js"; +import { getCommitsInRange } from "../git/log.js"; +import { resolveRange } from "../git/refs.js"; +import { detectRemoteRepo, getCommitDate } from "../github/client.js"; +import { fetchMergedPrsInRange } from "../github/prs.js"; +import type { + CollectSourcesConfig, + CommitInfo, + ReleaseRange, + ResolvedRange, + SourceItem, +} from "../types.js"; + +function commitToSourceItem( + commit: CommitInfo, + remote?: { owner: string; repo: string; host: string } | null, +): SourceItem { + const url = + remote?.host === "github.com" + ? `https://github.com/${remote.owner}/${remote.repo}/commit/${commit.sha}` + : `commit:${commit.sha}`; + + return { + id: `commit:${commit.sha}`, + type: "commit", + sha: commit.sha, + title: commit.title, + body: commit.body, + author: commit.author, + labels: [], + url, + }; +} + +function collectPrCommitShas(prItems: SourceItem[]): Set { + const shas = new Set(); + for (const item of prItems) { + if (item.sha) shas.add(item.sha); + } + return shas; +} + +export async function collectSources( + cwd: string, + range: ReleaseRange | ResolvedRange, + config: CollectSourcesConfig, +): Promise { + const resolved = + "fromSha" in range + ? range + : await resolveRange(cwd, range.fromRef, range.toRef); + + const remote = await detectRemoteRepo(cwd); + const [commits, fileChanges] = await Promise.all([ + getCommitsInRange(cwd, resolved.fromSha, resolved.toSha), + getFileChanges(cwd, resolved.fromSha, resolved.toSha, { + ignoreGlobs: config.ignoreGlobs, + maxDiffBytesPerFile: config.maxDiffBytesPerFile, + maxTotalDiffBytes: config.maxTotalDiffBytes, + }), + ]); + + const since = + (await getCommitDate(cwd, resolved.fromSha)) ?? new Date(0).toISOString(); + const until = + (await getCommitDate(cwd, resolved.toSha)) ?? new Date().toISOString(); + + const prItems = remote + ? await fetchMergedPrsInRange(remote, config.githubToken, since, until) + : []; + + const prCommitShas = collectPrCommitShas(prItems); + const orphanCommits = commits + .filter((commit) => !prCommitShas.has(commit.sha)) + .map((commit) => commitToSourceItem(commit, remote)); + + const diffItems: SourceItem[] = fileChanges.slice(0, 20).map((change) => ({ + id: `diff:${change.path}`, + type: "diff" as const, + title: `Change: ${change.path}`, + author: { login: "git", id: "git" }, + labels: [], + files: [change], + url: + remote?.host === "github.com" + ? `https://github.com/${remote.owner}/${remote.repo}/blob/HEAD/${change.path}` + : `file:${change.path}`, + })); + + return [...prItems, ...orphanCommits, ...diffItems]; +} diff --git a/packages/core/src/ingest/filter.ts b/packages/core/src/ingest/filter.ts new file mode 100644 index 0000000..3ee45a7 --- /dev/null +++ b/packages/core/src/ingest/filter.ts @@ -0,0 +1,12 @@ +import { minimatch } from "minimatch"; + +export function shouldIgnorePath(path: string, ignoreGlobs: string[]): boolean { + return ignoreGlobs.some((glob) => minimatch(path, glob, { dot: true })); +} + +export function applyIgnoreGlobs( + items: T[], + ignoreGlobs: string[], +): T[] { + return items.filter((item) => !shouldIgnorePath(item.path, ignoreGlobs)); +} diff --git a/packages/core/src/redact/index.ts b/packages/core/src/redact/index.ts new file mode 100644 index 0000000..b71a04f --- /dev/null +++ b/packages/core/src/redact/index.ts @@ -0,0 +1 @@ +export { redactSecrets } from "./secrets.js"; diff --git a/packages/core/src/redact/secrets.ts b/packages/core/src/redact/secrets.ts new file mode 100644 index 0000000..3592f47 --- /dev/null +++ b/packages/core/src/redact/secrets.ts @@ -0,0 +1,14 @@ +const SECRET_PATTERNS = [ + /AKIA[0-9A-Z]{16}/g, + /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/g, + /ghp_[A-Za-z0-9]{20,}/g, + /sk-[A-Za-z0-9]{20,}/g, +]; + +export function redactSecrets(text: string): string { + let result = text; + for (const pattern of SECRET_PATTERNS) { + result = result.replace(pattern, "[REDACTED]"); + } + return result; +} diff --git a/packages/core/src/semver/heuristics.ts b/packages/core/src/semver/heuristics.ts new file mode 100644 index 0000000..c5e5cf5 --- /dev/null +++ b/packages/core/src/semver/heuristics.ts @@ -0,0 +1,32 @@ +import type { SourceItem } from "../types.js"; + +const BREAKING_LABELS = new Set(["breaking", "breaking-change"]); +const FEAT_LABELS = new Set(["feat", "feature", "enhancement"]); + +export function suggestSemverFromSources( + sources: SourceItem[], +): "patch" | "minor" | "major" { + for (const source of sources) { + const labels = source.labels.map((l) => l.toLowerCase()); + const titleBody = `${source.title}\n${source.body ?? ""}`; + + if ( + labels.some((l) => BREAKING_LABELS.has(l)) || + /BREAKING CHANGE/i.test(titleBody) + ) { + return "major"; + } + } + + for (const source of sources) { + const labels = source.labels.map((l) => l.toLowerCase()); + if ( + labels.some((l) => FEAT_LABELS.has(l)) || + /^feat(\(|:)/i.test(source.title) + ) { + return "minor"; + } + } + + return "patch"; +} diff --git a/packages/core/src/semver/index.ts b/packages/core/src/semver/index.ts new file mode 100644 index 0000000..2b39ca5 --- /dev/null +++ b/packages/core/src/semver/index.ts @@ -0,0 +1 @@ +export { suggestSemverFromSources } from "./heuristics.js"; diff --git a/packages/core/src/sources/index.ts b/packages/core/src/sources/index.ts new file mode 100644 index 0000000..2762ae0 --- /dev/null +++ b/packages/core/src/sources/index.ts @@ -0,0 +1,15 @@ +export { git, tryGit } from "../git/exec.js"; +export { resolveRef, getLatestTag, resolveRange } from "../git/refs.js"; +export { getCommitsInRange } from "../git/log.js"; +export { getFileChanges } from "../git/diff.js"; + +export { + createOctokit, + detectRemoteRepo, + getCommitDate, + parseRemoteUrl, +} from "../github/client.js"; +export { fetchMergedPrsInRange, mapPrToSourceItem } from "../github/prs.js"; + +export { applyIgnoreGlobs, shouldIgnorePath } from "../ingest/filter.js"; +export { collectSources } from "../ingest/collect.js"; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts new file mode 100644 index 0000000..bbef081 --- /dev/null +++ b/packages/core/src/types.ts @@ -0,0 +1,131 @@ +export interface GitHubUser { + login: string; + id: string; + avatarUrl?: string; +} + +export interface FileChange { + path: string; + status: "added" | "modified" | "removed" | "renamed"; + previousPath?: string; + additions: number; + deletions: number; + patch?: string; +} + +export interface SourceItem { + id: string; + type: "commit" | "pr" | "diff"; + sha?: string; + prNumber?: number; + title: string; + body?: string; + author: GitHubUser; + mergedAt?: string; + labels: string[]; + files?: FileChange[]; + linkedIssues?: Array<{ number: number; title: string }>; + url: string; +} + +export interface ReleaseRange { + fromRef: string; + toRef: string; +} + +export interface ResolvedRange extends ReleaseRange { + fromSha: string; + toSha: string; +} + +export interface RemoteRepo { + host: string; + owner: string; + repo: string; +} + +export interface CommitInfo { + sha: string; + title: string; + body?: string; + author: GitHubUser; + committedAt: string; +} + +export interface DiffOptions { + ignoreGlobs?: string[]; + maxDiffBytesPerFile: number; + maxTotalDiffBytes: number; +} + +export interface CollectSourcesConfig extends DiffOptions { + ignoreGlobs: string[]; + githubToken?: string; +} + +export const DEFAULT_COLLECT_CONFIG: CollectSourcesConfig = { + ignoreGlobs: [ + "**/package-lock.json", + "**/pnpm-lock.yaml", + "**/yarn.lock", + "**/bun.lockb", + "**/dist/**", + "**/generated/**", + ], + maxDiffBytesPerFile: 20_000, + maxTotalDiffBytes: 400_000, +}; + +export type DraftSectionType = + | "summary" + | "breaking" + | "features" + | "fixes" + | "perf" + | "docs" + | "chore" + | "security" + | "migration" + | "credits"; + +export interface DraftSection { + type: DraftSectionType; + title: string; + content: string; + sourceIds: string[]; +} + +export interface Draft { + id: string; + repositoryId: string; + range: ReleaseRange; + status: "pending" | "approved" | "published" | "discarded"; + suggestedSemver: "patch" | "minor" | "major"; + heuristicSemver: "patch" | "minor" | "major"; + markdown: string; + sections: DraftSection[]; + contributors: GitHubUser[]; + model: { provider: string; name: string }; + createdBy?: string; + createdAt: string; + updatedAt: string; + publishedAt?: string; +} + +export type PolyScribeTone = + | "technical" + | "developer-friendly" + | "executive" + | "community"; + +export type Tone = PolyScribeTone; + +export type PublishTarget = "github-release" | "changelog-pr"; + +export type LLMProvider = "openai" | "anthropic" | "openai-compatible"; + +export interface GenerateDraftConfig { + tone?: Tone; + repositoryId?: string; + range?: ReleaseRange; +} diff --git a/packages/core/tests/changelog.test.ts b/packages/core/tests/changelog.test.ts new file mode 100644 index 0000000..d0b949b --- /dev/null +++ b/packages/core/tests/changelog.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { insertVersion, parseChangelog } from "../src/changelog/index.js"; + +const KEEP_A_CHANGELOG = `# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Draft changelog generation + +## [1.0.0] - 2024-01-15 + +### Added +- Initial release +`; + +describe("parseChangelog", () => { + it("parses Keep a Changelog sections", () => { + const parsed = parseChangelog(KEEP_A_CHANGELOG); + + expect(parsed.preamble).toContain("# Changelog"); + expect(parsed.unreleased?.version).toBe("Unreleased"); + expect(parsed.unreleased?.content).toContain("Draft changelog generation"); + expect(parsed.versions).toHaveLength(1); + expect(parsed.versions[0]?.version).toBe("1.0.0"); + expect(parsed.versions[0]?.date).toBe("2024-01-15"); + expect(parsed.versions[0]?.content).toContain("Initial release"); + }); +}); + +describe("insertVersion", () => { + it("inserts a new version while preserving Unreleased", () => { + const updated = insertVersion( + KEEP_A_CHANGELOG, + "1.1.0", + "2024-06-01", + `### Added +- Webhook retries`, + ); + + expect(updated).toContain("## [Unreleased]"); + expect(updated).toContain("Draft changelog generation"); + expect(updated).toContain("## [1.1.0] - 2024-06-01"); + expect(updated).toContain("Webhook retries"); + expect(updated).toContain("## [1.0.0] - 2024-01-15"); + expect(updated.indexOf("## [Unreleased]")).toBeLessThan( + updated.indexOf("## [1.1.0]"), + ); + expect(updated.indexOf("## [1.1.0]")).toBeLessThan( + updated.indexOf("## [1.0.0]"), + ); + }); +}); diff --git a/packages/core/tests/config.test.ts b/packages/core/tests/config.test.ts new file mode 100644 index 0000000..ee2c0f1 --- /dev/null +++ b/packages/core/tests/config.test.ts @@ -0,0 +1,121 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, describe, expect, it } from "vitest"; +import { + defaultConfig, + loadConfig, + parseConfig, + polyScribeConfigSchema, +} from "../src/config/index.js"; + +describe("polyScribeConfigSchema", () => { + it("applies defaults for an empty object", () => { + const config = polyScribeConfigSchema.parse({}); + + expect(config.changelogPath).toBe("CHANGELOG.md"); + expect(config.tone).toBe("developer-friendly"); + expect(config.requireApprover).toBe(true); + expect(config.autoPublish).toBe(false); + expect(config.includeUnreleased).toBe(false); + expect(config.publishTargets).toEqual(["github-release", "changelog-pr"]); + expect(config.ignoreGlobs).toContain("**/dist/**"); + expect(config.maxDiffBytesPerFile).toBe(20_000); + expect(config.maxTotalDiffBytes).toBe(400_000); + expect(config.llm).toBeUndefined(); + }); + + it("accepts partial overrides while keeping other defaults", () => { + const config = parseConfig({ + tone: "technical", + requireApprover: false, + maxDiffBytesPerFile: 10_000, + }); + + expect(config.tone).toBe("technical"); + expect(config.requireApprover).toBe(false); + expect(config.maxDiffBytesPerFile).toBe(10_000); + expect(config.changelogPath).toBe("CHANGELOG.md"); + expect(config.autoPublish).toBe(false); + }); + + it("matches defaultConfig output", () => { + expect(defaultConfig()).toEqual(polyScribeConfigSchema.parse({})); + }); +}); + +describe("loadConfig", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + }); + + function createFixtureDir(): string { + const dir = mkdtempSync(join(tmpdir(), "polyscribe-config-")); + tempDirs.push(dir); + return dir; + } + + it("returns defaults when no config files exist", () => { + const cwd = createFixtureDir(); + const result = loadConfig(cwd); + + expect(result.path).toBeUndefined(); + expect(result.config).toEqual(defaultConfig()); + }); + + it("loads a single config file", () => { + const cwd = createFixtureDir(); + writeFileSync( + join(cwd, ".polyscribe.yml"), + `tone: executive +autoPublish: true +`, + "utf8", + ); + + const result = loadConfig(cwd); + + expect(result.path).toBe(join(cwd, ".polyscribe.yml")); + expect(result.config.tone).toBe("executive"); + expect(result.config.autoPublish).toBe(true); + expect(result.config.requireApprover).toBe(true); + }); + + it("deep-merges .github/polyscribe.yml and .polyscribe.yml", () => { + const cwd = createFixtureDir(); + mkdirSync(join(cwd, ".github"), { recursive: true }); + + writeFileSync( + join(cwd, ".github/polyscribe.yml"), + `tone: technical +sections: + order: + - summary + - fixes +`, + "utf8", + ); + writeFileSync( + join(cwd, ".polyscribe.yml"), + `requireApprover: false +sections: + order: + - features + - summary +`, + "utf8", + ); + + const result = loadConfig(cwd); + + expect(result.path).toBe(join(cwd, ".polyscribe.yml")); + expect(result.config.tone).toBe("technical"); + expect(result.config.requireApprover).toBe(false); + expect(result.config.sections?.order).toEqual(["features", "summary"]); + expect(existsSync(join(cwd, ".github/polyscribe.yml"))).toBe(true); + }); +}); diff --git a/packages/core/tests/filter.test.ts b/packages/core/tests/filter.test.ts new file mode 100644 index 0000000..801644d --- /dev/null +++ b/packages/core/tests/filter.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { applyIgnoreGlobs } from "../src/sources/index.js"; + +interface PathItem { + path: string; +} + +describe("applyIgnoreGlobs", () => { + const items: PathItem[] = [ + { path: "src/index.ts" }, + { path: "dist/index.js" }, + { path: "packages/core/dist/bundle.js" }, + { path: "pnpm-lock.yaml" }, + { path: ".env.local" }, + ]; + + const ignoreGlobs = [ + "**/dist/**", + "**/pnpm-lock.yaml", + "**/package-lock.json", + ]; + + it("filters paths matching minimatch globs", () => { + const filtered = applyIgnoreGlobs(items, ignoreGlobs); + + expect(filtered).toEqual([ + { path: "src/index.ts" }, + { path: ".env.local" }, + ]); + }); + + it("returns all items when ignore list is empty", () => { + expect(applyIgnoreGlobs(items, [])).toEqual(items); + }); + + it("matches nested dist directories", () => { + const filtered = applyIgnoreGlobs( + [{ path: "apps/web/dist/client/main.js" }], + ["**/dist/**"], + ); + + expect(filtered).toEqual([]); + }); +}); diff --git a/packages/core/tests/prompt.test.ts b/packages/core/tests/prompt.test.ts new file mode 100644 index 0000000..1eb7fd9 --- /dev/null +++ b/packages/core/tests/prompt.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { + buildDraftPrompts, + buildDraftSystemPrompt, + buildDraftUserPrompt, + formatSourceForPrompt, +} from "../src/draft/prompt.js"; +import type { SourceItem } from "../src/types.js"; + +const sampleSources: SourceItem[] = [ + { + id: "pr:128", + type: "pr", + prNumber: 128, + title: "feat: add webhook retries", + body: "Implements exponential backoff for failed webhook deliveries.", + author: { login: "alice", id: "1" }, + labels: ["feat", "enhancement"], + url: "https://github.com/acme/api/pull/128", + linkedIssues: [{ number: 120, title: "Webhook failures" }], + }, + { + id: "commit:abc123def456", + type: "commit", + sha: "abc123def4567890", + title: "fix: pagination off-by-one", + author: { login: "bob", id: "2" }, + labels: ["fix"], + url: "commit:abc123def4567890", + }, +]; + +describe("buildDraftSystemPrompt", () => { + it("includes tone-specific guidance", () => { + const technical = buildDraftSystemPrompt("technical"); + const community = buildDraftSystemPrompt("community"); + + expect(technical).toContain("API-oriented"); + expect(community).toContain("credits-forward"); + expect(technical).toContain("sourceIds"); + }); +}); + +describe("formatSourceForPrompt", () => { + it("formats PR sources with metadata", () => { + const formatted = formatSourceForPrompt(sampleSources[0]!); + + expect(formatted).toContain("id: pr:128"); + expect(formatted).toContain("pr: #128"); + expect(formatted).toContain("labels: feat, enhancement"); + expect(formatted).toContain("linked_issues: #120 Webhook failures"); + }); +}); + +describe("buildDraftUserPrompt", () => { + it("includes tone, heuristic semver, and all sources", () => { + const prompt = buildDraftUserPrompt( + sampleSources, + "developer-friendly", + "minor", + ); + + expect(prompt).toContain("Tone: developer-friendly"); + expect(prompt).toContain("Heuristic semver suggestion: minor"); + expect(prompt).toContain("Sources (2):"); + expect(prompt).toContain("id: pr:128"); + expect(prompt).toContain("id: commit:abc123def456"); + }); + + it("handles empty sources", () => { + const prompt = buildDraftUserPrompt([], "technical", "patch"); + expect(prompt).toContain("No sources in range."); + }); +}); + +describe("buildDraftPrompts", () => { + it("returns paired system and user prompts", () => { + const prompts = buildDraftPrompts(sampleSources, "executive", "minor"); + + expect(prompts.system).toContain("outcome-focused"); + expect(prompts.user).toContain("Sources (2):"); + }); +}); diff --git a/packages/core/tests/redact.test.ts b/packages/core/tests/redact.test.ts new file mode 100644 index 0000000..e71c685 --- /dev/null +++ b/packages/core/tests/redact.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { redactSecrets } from "../src/redact/index.js"; + +describe("redactSecrets", () => { + it("redacts AWS access key ids", () => { + const input = "export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE"; + expect(redactSecrets(input)).toBe("export AWS_ACCESS_KEY_ID=[REDACTED]"); + }); + + it("redacts GitHub personal access tokens", () => { + const input = "token=ghp_abcdefghijklmnopqrstuvwxyz123456"; + expect(redactSecrets(input)).toBe("token=[REDACTED]"); + }); + + it("redacts OpenAI-style API keys", () => { + const input = "Authorization: Bearer sk-abcdefghijklmnopqrstuv"; + expect(redactSecrets(input)).toBe("Authorization: Bearer [REDACTED]"); + }); + + it("redacts private key headers", () => { + const input = `-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEA... +-----END RSA PRIVATE KEY-----`; + expect(redactSecrets(input)).toContain("[REDACTED]"); + expect(redactSecrets(input)).not.toContain("BEGIN RSA PRIVATE KEY"); + }); + + it("redacts multiple secret types in one string", () => { + const input = [ + "AKIAIOSFODNN7EXAMPLE", + "ghp_abcdefghijklmnopqrstuvwxyz123456", + "sk-abcdefghijklmnopqrstuv", + ].join("\n"); + + const redacted = redactSecrets(input); + expect(redacted).not.toContain("AKIAIOSFODNN7EXAMPLE"); + expect(redacted).not.toContain("ghp_abcdefghijklmnopqrstuvwxyz123456"); + expect(redacted).not.toContain("sk-abcdefghijklmnopqrstuv"); + expect(redacted.match(/\[REDACTED\]/g)?.length).toBe(3); + }); +}); diff --git a/packages/core/tests/render.test.ts b/packages/core/tests/render.test.ts new file mode 100644 index 0000000..abd16a9 --- /dev/null +++ b/packages/core/tests/render.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { + getSectionTitle, + renderDraftMarkdown, + renderKeepAChangelogBody, + sortSections, +} from "../src/draft/render.js"; +import type { DraftSection } from "../src/types.js"; + +const sections: DraftSection[] = [ + { + type: "fixes", + title: "Fixes", + content: "- Installations list no longer drops page 2 results (#131)", + sourceIds: ["pr:131"], + }, + { + type: "summary", + title: "Summary", + content: + "This release adds webhook retries and fixes a pagination bug in the installations API.", + sourceIds: ["pr:128", "pr:131"], + }, + { + type: "features", + title: "Features", + content: + "- **Webhook retries** — failed deliveries retry with exponential backoff (@alice, #128)", + sourceIds: ["pr:128"], + }, + { + type: "credits", + title: "Contributors", + content: "Thanks to @alice, @bob, and @carol.", + sourceIds: [], + }, +]; + +describe("renderDraftMarkdown", () => { + it("renders non-empty sections in default order", () => { + const markdown = renderDraftMarkdown(sections); + + expect(markdown).toMatch(/^## Summary/); + expect(markdown.indexOf("## Summary")).toBeLessThan( + markdown.indexOf("## Features"), + ); + expect(markdown.indexOf("## Features")).toBeLessThan( + markdown.indexOf("## Fixes"), + ); + expect(markdown).toContain("## Contributors"); + expect(markdown).toContain("webhook retries"); + }); + + it("omits empty sections", () => { + const withEmpty: DraftSection[] = [ + ...sections, + { type: "security", title: "Security", content: " ", sourceIds: [] }, + ]; + + expect(renderDraftMarkdown(withEmpty)).not.toContain("## Security"); + }); +}); + +describe("renderKeepAChangelogBody", () => { + it("maps sections to Keep a Changelog headings", () => { + const body = renderKeepAChangelogBody(sections); + + expect(body).toContain("webhook retries"); + expect(body).toContain("### Added"); + expect(body).toContain("### Fixed"); + expect(body).not.toContain("## Contributors"); + }); +}); + +describe("sortSections", () => { + it("orders sections by configured priority", () => { + const sorted = sortSections(sections); + expect(sorted.map((s) => s.type)).toEqual([ + "summary", + "features", + "fixes", + "credits", + ]); + }); +}); + +describe("getSectionTitle", () => { + it("returns human-readable titles", () => { + expect(getSectionTitle("breaking")).toBe("Breaking Changes"); + expect(getSectionTitle("migration")).toBe("Migration Guide"); + }); +}); diff --git a/packages/core/tests/semver.test.ts b/packages/core/tests/semver.test.ts new file mode 100644 index 0000000..0e8a87a --- /dev/null +++ b/packages/core/tests/semver.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { suggestSemverFromSources } from "../src/semver/index.js"; +import type { SourceItem } from "../src/types.js"; + +function makeSource(overrides: Partial & Pick): SourceItem { + return { + id: "pr:1", + type: "pr", + author: { login: "alice", id: "1" }, + labels: [], + url: "https://example.com/pr/1", + ...overrides, + }; +} + +describe("suggestSemverFromSources", () => { + it("suggests patch for routine fixes", () => { + const sources = [ + makeSource({ + title: "fix: handle null pagination cursor", + labels: ["fix", "bug"], + }), + ]; + + expect(suggestSemverFromSources(sources)).toBe("patch"); + }); + + it("suggests minor for feature labels", () => { + const sources = [ + makeSource({ + title: "Add webhook retries", + labels: ["feat"], + }), + ]; + + expect(suggestSemverFromSources(sources)).toBe("minor"); + }); + + it("suggests minor for conventional feat commits", () => { + const sources = [ + makeSource({ + title: "feat(api): expose rate limit headers", + labels: [], + }), + ]; + + expect(suggestSemverFromSources(sources)).toBe("minor"); + }); + + it("suggests major for breaking labels", () => { + const sources = [ + makeSource({ + title: "Remove legacy auth endpoint", + labels: ["breaking-change"], + }), + ]; + + expect(suggestSemverFromSources(sources)).toBe("major"); + }); + + it("suggests major when BREAKING CHANGE appears in body", () => { + const sources = [ + makeSource({ + title: "Refactor token validation", + body: "BREAKING CHANGE: dropped support for v1 tokens", + labels: ["enhancement"], + }), + ]; + + expect(suggestSemverFromSources(sources)).toBe("major"); + }); + + it("prioritizes major over minor when both signals exist", () => { + const sources = [ + makeSource({ + title: "feat!: remove deprecated client", + labels: ["feat", "breaking"], + body: "Adds new client while removing the old one.", + }), + ]; + + expect(suggestSemverFromSources(sources)).toBe("major"); + }); +}); diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts new file mode 100644 index 0000000..6d54b25 --- /dev/null +++ b/packages/core/tsup.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + clean: true, + sourcemap: true, + external: [ + "openai", + "@anthropic-ai/sdk", + "zod", + "yaml", + "@octokit/rest", + "execa", + "minimatch", + ], +}); diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 0000000..19384e8 --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06bf911..5889d76 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,12 @@ importers: '@polyscribe/core': specifier: workspace:* version: link:../core + commander: + specifier: ^13.0.0 + version: 13.1.0 + picocolors: + specifier: ^1.1.1 + version: 1.1.1 devDependencies: '@types/node': specifier: ^22.15.30 @@ -44,6 +50,28 @@ importers: version: 3.2.7(@types/node@22.20.1)(yaml@2.9.0) packages/core: + dependencies: + '@anthropic-ai/sdk': + specifier: ^0.39.0 + version: 0.39.0 + '@octokit/rest': + specifier: ^22.0.0 + version: 22.0.1 + execa: + specifier: ^9.6.0 + version: 9.6.1 + minimatch: + specifier: ^10.0.3 + version: 10.2.5 + openai: + specifier: ^4.77.0 + version: 4.104.0(zod@3.25.76) + yaml: + specifier: ^2.8.0 + version: 2.9.0 + zod: + specifier: ^3.24.2 + version: 3.25.76 devDependencies: '@types/node': specifier: ^22.15.30 @@ -60,6 +88,9 @@ importers: packages: + '@anthropic-ai/sdk@0.39.0': + resolution: {integrity: sha512-eMyDIPRZbt1CCLErRCi3exlAvNkBtRe+kW5vvJyef93PmNr/clstYgHhtvmkxN82nlKgzyGPCyGxrm0JQ1ZIdg==} + '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} @@ -443,6 +474,58 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@octokit/auth-token@6.0.0': + resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} + engines: {node: '>= 20'} + + '@octokit/core@7.0.6': + resolution: {integrity: sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==} + engines: {node: '>= 20'} + + '@octokit/endpoint@11.0.3': + resolution: {integrity: sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==} + engines: {node: '>= 20'} + + '@octokit/graphql@9.0.3': + resolution: {integrity: sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==} + engines: {node: '>= 20'} + + '@octokit/openapi-types@27.0.0': + resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} + + '@octokit/plugin-paginate-rest@14.0.0': + resolution: {integrity: sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-request-log@6.0.0': + resolution: {integrity: sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/plugin-rest-endpoint-methods@17.0.0': + resolution: {integrity: sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=6' + + '@octokit/request-error@7.1.0': + resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==} + engines: {node: '>= 20'} + + '@octokit/request@10.0.11': + resolution: {integrity: sha512-+s7HUxjfFqOMS9VlIwDffq0MikjSAK0gSpG73W+meAvVAvX4MBrHYTK5Bj3Uot55qFT4gzUtfzE4mGWY4Br8/Q==} + engines: {node: '>= 20'} + + '@octokit/rest@22.0.1': + resolution: {integrity: sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==} + engines: {node: '>= 20'} + + '@octokit/types@16.0.0': + resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} + '@rollup/rollup-android-arm-eabi@4.62.2': resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} cpu: [arm] @@ -568,6 +651,13 @@ packages: cpu: [x64] os: [win32] + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + '@turbo/darwin-64@2.10.4': resolution: {integrity: sha512-m1MUEI4MJ69r5CwfMYxmHi0H0rrgiYCBOp0tgBZ9x/YVvOb5uu/lRIDyDwdtH054R2yWeQaIigUGu6aCX9f8cA==} cpu: [x64] @@ -610,6 +700,12 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/node-fetch@2.6.13': + resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} + + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@22.20.1': resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} @@ -701,6 +797,10 @@ packages: '@vitest/utils@3.2.7': resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -711,6 +811,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} + ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} @@ -728,6 +832,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -735,6 +842,9 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + before-after-hook@4.0.0: + resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + brace-expansion@1.1.16: resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} @@ -752,6 +862,10 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -779,6 +893,14 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -793,6 +915,10 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -813,9 +939,33 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -879,6 +1029,14 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -901,6 +1059,10 @@ packages: picomatch: optional: true + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -919,11 +1081,37 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + form-data-encoder@1.7.2: + resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + formdata-node@4.4.1: + resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} + engines: {node: '>= 12.20'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -932,10 +1120,33 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -960,6 +1171,18 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -983,6 +1206,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-with-bigint@3.5.10: + resolution: {integrity: sha512-Vcx+JVNEBts/xfcoCS69sKrOhOk/3TVlvlT+XzUOefVKnnrbYSCKpDCm10pohsJFtsJVYnwa/cXRZ4eElzaM6w==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1014,6 +1240,18 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -1038,10 +1276,40 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + openai@4.104.0: + resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.23.8 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1058,6 +1326,10 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -1066,6 +1338,10 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1113,6 +1389,10 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1150,6 +1430,10 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1164,6 +1448,10 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -1209,6 +1497,9 @@ packages: resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} engines: {node: '>=14.0.0'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -1264,9 +1555,19 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + universal-user-agent@7.0.3: + resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1343,6 +1644,16 @@ packages: jsdom: optional: true + web-streams-polyfill@4.0.0-beta.3: + resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==} + engines: {node: '>= 14'} + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -1366,8 +1677,27 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + snapshots: + '@anthropic-ai/sdk@0.39.0': + dependencies: + '@types/node': 18.19.130 + '@types/node-fetch': 2.6.13 + abort-controller: 3.0.0 + agentkeepalive: 4.6.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + '@esbuild/aix-ppc64@0.27.7': optional: true @@ -1600,6 +1930,69 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@octokit/auth-token@6.0.0': {} + + '@octokit/core@7.0.6': + dependencies: + '@octokit/auth-token': 6.0.0 + '@octokit/graphql': 9.0.3 + '@octokit/request': 10.0.11 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + before-after-hook: 4.0.0 + universal-user-agent: 7.0.3 + + '@octokit/endpoint@11.0.3': + dependencies: + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + + '@octokit/graphql@9.0.3': + dependencies: + '@octokit/request': 10.0.11 + '@octokit/types': 16.0.0 + universal-user-agent: 7.0.3 + + '@octokit/openapi-types@27.0.0': {} + + '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/types': 16.0.0 + + '@octokit/plugin-request-log@6.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + + '@octokit/plugin-rest-endpoint-methods@17.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/types': 16.0.0 + + '@octokit/request-error@7.1.0': + dependencies: + '@octokit/types': 16.0.0 + + '@octokit/request@10.0.11': + dependencies: + '@octokit/endpoint': 11.0.3 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + content-type: 2.0.0 + json-with-bigint: 3.5.10 + universal-user-agent: 7.0.3 + + '@octokit/rest@22.0.1': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/plugin-paginate-rest': 14.0.0(@octokit/core@7.0.6) + '@octokit/plugin-request-log': 6.0.0(@octokit/core@7.0.6) + '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.6) + + '@octokit/types@16.0.0': + dependencies: + '@octokit/openapi-types': 27.0.0 + '@rollup/rollup-android-arm-eabi@4.62.2': optional: true @@ -1675,6 +2068,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@sec-ant/readable-stream@0.4.1': {} + + '@sindresorhus/merge-streams@4.0.0': {} + '@turbo/darwin-64@2.10.4': optional: true @@ -1704,6 +2101,15 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/node-fetch@2.6.13': + dependencies: + '@types/node': 22.20.1 + form-data: 4.0.6 + + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + '@types/node@22.20.1': dependencies: undici-types: 6.21.0 @@ -1841,12 +2247,20 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: acorn: 8.17.0 acorn@8.17.0: {} + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + ajv@6.15.0: dependencies: fast-deep-equal: 3.1.3 @@ -1864,10 +2278,14 @@ snapshots: assertion-error@2.0.1: {} + asynckit@0.4.0: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} + before-after-hook@4.0.0: {} + brace-expansion@1.1.16: dependencies: balanced-match: 1.0.2 @@ -1884,6 +2302,11 @@ snapshots: cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + callsites@3.1.0: {} chai@5.3.3: @@ -1911,6 +2334,12 @@ snapshots: color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@13.1.0: {} + commander@4.1.1: {} concat-map@0.0.1: {} @@ -1919,6 +2348,8 @@ snapshots: consola@3.4.2: {} + content-type@2.0.0: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -1933,8 +2364,31 @@ snapshots: deep-is@0.1.4: {} + delayed-stream@1.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -2067,6 +2521,23 @@ snapshots: esutils@2.0.3: {} + event-target-shim@5.0.1: {} + + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + expect-type@1.4.0: {} fast-deep-equal@3.1.3: {} @@ -2079,6 +2550,10 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -2101,17 +2576,75 @@ snapshots: flatted@3.4.2: {} + form-data-encoder@1.7.2: {} + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + formdata-node@4.4.1: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 4.0.0-beta.3 + fsevents@2.3.3: optional: true + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 globals@14.0.0: {} + gopd@1.2.0: {} + has-flag@4.0.0: {} + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + human-signals@8.0.1: {} + + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + ignore@5.3.2: {} ignore@7.0.6: {} @@ -2129,6 +2662,12 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-plain-obj@4.1.0: {} + + is-stream@4.0.1: {} + + is-unicode-supported@2.1.0: {} + isexe@2.0.0: {} joycon@3.1.1: {} @@ -2145,6 +2684,8 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json-with-bigint@3.5.10: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -2172,6 +2713,14 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + minimatch@10.2.5: dependencies: brace-expansion: 5.0.7 @@ -2199,8 +2748,33 @@ snapshots: natural-compare@1.4.0: {} + node-domexception@1.0.0: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + object-assign@4.1.1: {} + openai@4.104.0(zod@3.25.76): + dependencies: + '@types/node': 18.19.130 + '@types/node-fetch': 2.6.13 + abort-controller: 3.0.0 + agentkeepalive: 4.6.0 + form-data-encoder: 1.7.2 + formdata-node: 4.4.1 + node-fetch: 2.7.0 + optionalDependencies: + zod: 3.25.76 + transitivePeerDependencies: + - encoding + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -2222,10 +2796,14 @@ snapshots: dependencies: callsites: 3.1.0 + parse-ms@4.0.0: {} + path-exists@4.0.0: {} path-key@3.1.1: {} + path-key@4.0.0: {} + pathe@2.0.3: {} pathval@2.0.1: {} @@ -2257,6 +2835,10 @@ snapshots: prelude-ls@1.2.1: {} + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + punycode@2.3.1: {} readdirp@4.1.2: {} @@ -2306,6 +2888,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@4.1.0: {} + source-map-js@1.2.1: {} source-map@0.7.6: {} @@ -2314,6 +2898,8 @@ snapshots: std-env@3.10.0: {} + strip-final-newline@4.0.0: {} + strip-json-comments@3.1.1: {} strip-literal@3.1.0: @@ -2357,6 +2943,8 @@ snapshots: tinyspy@4.0.4: {} + tr46@0.0.3: {} + tree-kill@1.2.2: {} ts-api-utils@2.5.0(typescript@5.9.3): @@ -2421,8 +3009,14 @@ snapshots: ufo@1.6.4: {} + undici-types@5.26.5: {} + undici-types@6.21.0: {} + unicorn-magic@0.3.0: {} + + universal-user-agent@7.0.3: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -2502,6 +3096,15 @@ snapshots: - tsx - yaml + web-streams-polyfill@4.0.0-beta.3: {} + + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which@2.0.2: dependencies: isexe: 2.0.0 @@ -2513,7 +3116,10 @@ snapshots: word-wrap@1.2.5: {} - yaml@2.9.0: - optional: true + yaml@2.9.0: {} yocto-queue@0.1.0: {} + + yoctocolors@2.1.2: {} + + zod@3.25.76: {} From a2bea78b6160411224d707c0461bc7f55f1b75e2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 11 Jul 2026 13:16:32 +0000 Subject: [PATCH 4/7] feat(cli): add sources command and enhance changelog/draft/publish - Add polyscribe sources command with --from/--to/--json/--pretty/--count - Enhance changelog with --dry-run, --date, and --notes (skips LLM) - Add draft --sources-only/--no-llm aliases and spec exit codes - Add publish stub with RELEASE.md integration help text - Extract shared gather-sources, print-sources, format-date, line-diff utils - Add CLI tests for sources registration and changelog dry-run helpers Co-authored-by: Moses Man --- packages/cli/package.json | 34 +++- packages/cli/src/commands/changelog.ts | 187 +++++++++++-------- packages/cli/src/commands/doctor.ts | 68 ++++++- packages/cli/src/commands/draft.ts | 157 ++++++++-------- packages/cli/src/commands/publish.ts | 38 ++++ packages/cli/src/commands/sources.ts | 43 +++++ packages/cli/src/index.ts | 4 + packages/cli/src/lib/gather-sources.ts | 47 +++++ packages/cli/src/lib/handle-error.ts | 66 +++++++ packages/cli/src/lib/print-sources.ts | 26 +++ packages/cli/src/util/exit-codes.ts | 5 + packages/cli/src/util/format-date.ts | 13 ++ packages/cli/src/util/line-diff.ts | 64 +++++++ packages/cli/tests/changelog-dry-run.test.ts | 40 ++++ packages/cli/tests/sources.test.ts | 122 ++++++++++++ packages/cli/vitest.config.ts | 7 + 16 files changed, 767 insertions(+), 154 deletions(-) create mode 100644 packages/cli/src/commands/publish.ts create mode 100644 packages/cli/src/commands/sources.ts create mode 100644 packages/cli/src/lib/gather-sources.ts create mode 100644 packages/cli/src/lib/handle-error.ts create mode 100644 packages/cli/src/lib/print-sources.ts create mode 100644 packages/cli/src/util/exit-codes.ts create mode 100644 packages/cli/src/util/format-date.ts create mode 100644 packages/cli/src/util/line-diff.ts create mode 100644 packages/cli/tests/changelog-dry-run.test.ts create mode 100644 packages/cli/tests/sources.test.ts create mode 100644 packages/cli/vitest.config.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 421801f..ebe1379 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,31 @@ { "name": "@polyscribe/cli", - "version": "0.0.0", + "version": "0.1.0", + "description": "CLI for PolyScribe — evidence-linked release notes for GitHub", "type": "module", + "license": "MIT", + "author": "LatticeAG", + "repository": { + "type": "git", + "url": "git+https://github.com/LatticeAG/PolyScribe.git", + "directory": "packages/cli" + }, + "homepage": "https://github.com/LatticeAG/PolyScribe#readme", + "bugs": { + "url": "https://github.com/LatticeAG/PolyScribe/issues" + }, + "keywords": [ + "polyscribe", + "cli", + "release-notes", + "changelog", + "github", + "semver", + "llm" + ], + "publishConfig": { + "access": "public" + }, "bin": { "polyscribe": "./dist/index.js" }, @@ -18,9 +42,10 @@ ], "scripts": { "build": "tsup src/index.ts --format esm --dts --clean", - "test": "vitest run --passWithNoTests", + "test": "vitest run", "lint": "eslint src", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "prepublishOnly": "pnpm run build" }, "dependencies": { "@polyscribe/core": "workspace:*", @@ -32,5 +57,8 @@ "tsup": "^8.5.0", "typescript": "^5.8.3", "vitest": "^3.2.4" + }, + "engines": { + "node": ">=22" } } diff --git a/packages/cli/src/commands/changelog.ts b/packages/cli/src/commands/changelog.ts index f027bc6..5bdb236 100644 --- a/packages/cli/src/commands/changelog.ts +++ b/packages/cli/src/commands/changelog.ts @@ -3,7 +3,6 @@ import { resolve } from "node:path"; import type { Command } from "commander"; import pc from "picocolors"; import { - collectSources, createEmptyChangelog, createLLMClient, generateDraft, @@ -11,13 +10,22 @@ import { loadConfig, renderKeepAChangelogBody, resolveRange, + type ResolvedRange, } from "@polyscribe/core"; +import { gatherSources } from "../lib/gather-sources.js"; +import { handleCommandError } from "../lib/handle-error.js"; +import { formatDateUtc } from "../util/format-date.js"; +import { renderLineDiff } from "../util/line-diff.js"; +import { EXIT_CONFIG } from "../util/exit-codes.js"; export interface ChangelogCommandOptions { from?: string; to?: string; version?: string; write?: boolean; + dryRun?: boolean; + date?: string; + notes?: string; } export function registerChangelogCommand(program: Command): void { @@ -26,88 +34,115 @@ export function registerChangelogCommand(program: Command): void { .description("Generate and optionally write Keep a Changelog entry") .option("--from ", "start ref") .option("--to ", "end ref (default: HEAD)") - .option("--version ", "version for changelog heading (required with --write)") + .option("--version ", "version for changelog heading") .option("--write", "update changelog file in working tree") + .option("--dry-run", "show full changelog file diff without writing") + .option("--date ", "override release date (default: today UTC)") + .option( + "--notes ", + "use existing markdown file as changelog body (skips LLM)", + ) .action(async (options: ChangelogCommandOptions) => { const cwd = process.cwd(); - const { config } = loadConfig(cwd); - const changelogPath = resolve(cwd, config.changelogPath); - - const range = await resolveRange( - cwd, - options.from, - options.to ?? "HEAD", - ); - - console.log(pc.dim("Collecting sources...")); - const sources = await collectSources( - cwd, - { - fromRef: range.fromRef, - toRef: range.toRef, - }, - { - ignoreGlobs: config.ignoreGlobs, - maxDiffBytesPerFile: config.maxDiffBytesPerFile, - maxTotalDiffBytes: config.maxTotalDiffBytes, - githubToken: process.env.GITHUB_TOKEN, - }, - ); - - if (sources.length === 0) { - console.error( - pc.yellow( - `No sources found for range ${range.fromRef}..${range.toRef}`, - ), - ); - process.exit(1); - } - const llm = createLLMClient({ - provider: config.llm?.provider, - model: config.llm?.model, - }); - - console.log(pc.dim(`Generating changelog body with ${llm.provider}/${llm.model}...`)); - - const draft = await generateDraft( - sources, - { - tone: config.tone, - repositoryId: "local", - range: { fromRef: range.fromRef, toRef: range.toRef }, - }, - llm, - ); - - const body = renderKeepAChangelogBody( - draft.sections, - config.sections?.order, - ); - - if (!options.write) { - console.log(""); - console.log(body); - console.log(""); - console.log(pc.dim(`Range: ${range.fromRef}..${range.toRef}`)); - return; - } + try { + const { config } = loadConfig(cwd); + let range: ResolvedRange; + let body: string; - if (!options.version) { - console.error(pc.red("--version is required when using --write")); - process.exit(1); - } + if (options.notes) { + const notesPath = resolve(cwd, options.notes); + if (!existsSync(notesPath)) { + console.error(pc.red(`Notes file not found: ${options.notes}`)); + process.exit(EXIT_CONFIG); + } + + range = await resolveRange( + cwd, + options.from, + options.to ?? "HEAD", + ); + body = readFileSync(notesPath, "utf8").trimEnd(); + } else { + console.error(pc.dim("Collecting sources...")); + const gathered = await gatherSources(cwd, { + from: options.from, + to: options.to, + }); + range = gathered.range; + + if (gathered.sources.length === 0) { + console.error( + pc.yellow( + `No sources found for range ${range.fromRef}..${range.toRef}`, + ), + ); + process.exit(EXIT_CONFIG); + } + + const llm = createLLMClient({ + provider: config.llm?.provider, + model: config.llm?.model, + }); + + console.error( + pc.dim( + `Generating changelog body with ${llm.provider}/${llm.model}...`, + ), + ); - const existing = existsSync(changelogPath) - ? readFileSync(changelogPath, "utf8") - : createEmptyChangelog(); + const draft = await generateDraft( + gathered.sources, + { + tone: config.tone, + repositoryId: "local", + range: { fromRef: range.fromRef, toRef: range.toRef }, + }, + llm, + ); - const date = new Date().toISOString().slice(0, 10); - const updated = insertVersion(existing, options.version, date, body); + body = renderKeepAChangelogBody( + draft.sections, + config.sections?.order, + ); + } - writeFileSync(changelogPath, updated, "utf8"); - console.log( - pc.green(`Updated ${config.changelogPath} for v${options.version}`), - ); + const changelogPath = resolve(cwd, config.changelogPath); + const needsFileUpdate = options.write || options.dryRun; + + if (!needsFileUpdate) { + console.log(""); + console.log(body); + console.log(""); + console.log(pc.dim(`Range: ${range.fromRef}..${range.toRef}`)); + return; + } + + if (!options.version) { + console.error( + pc.red("--version is required when using --write or --dry-run"), + ); + process.exit(EXIT_CONFIG); + } + + const existing = existsSync(changelogPath) + ? readFileSync(changelogPath, "utf8") + : createEmptyChangelog(); + + const date = formatDateUtc(options.date); + const updated = insertVersion(existing, options.version, date, body); + + if (options.dryRun) { + console.log(renderLineDiff(existing, updated)); + return; + } + + writeFileSync(changelogPath, updated, "utf8"); + console.log( + pc.green(`Updated ${config.changelogPath} for v${options.version}`), + ); + } catch (error) { + handleCommandError(error); + } }); } diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index fafb807..a294593 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -1,7 +1,15 @@ +import { accessSync, constants, existsSync } from "node:fs"; import { execSync } from "node:child_process"; +import { dirname, join } from "node:path"; import type { Command } from "commander"; import pc from "picocolors"; -import { findConfigPath, hasLLMCredentials, loadConfig } from "@polyscribe/core"; +import { + findConfigPath, + getLatestTag, + hasLLMCredentials, + loadConfig, + resolveRange, +} from "@polyscribe/core"; function isGitRepo(cwd: string): boolean { try { @@ -35,11 +43,41 @@ function checkLLM(configProvider?: string): { ok: boolean; message: string } { }; } +function checkChangelogWritable( + cwd: string, + changelogPath: string, +): { ok: boolean; message: string } { + const absolutePath = join(cwd, changelogPath); + + if (existsSync(absolutePath)) { + try { + accessSync(absolutePath, constants.W_OK); + return { ok: true, message: `${changelogPath} is writable` }; + } catch { + return { ok: false, message: `${changelogPath} exists but is not writable` }; + } + } + + const parent = dirname(absolutePath); + try { + accessSync(parent, constants.W_OK); + return { + ok: true, + message: `${changelogPath} can be created (parent directory writable)`, + }; + } catch { + return { + ok: false, + message: `Cannot write ${changelogPath} (parent directory not writable)`, + }; + } +} + export function registerDoctorCommand(program: Command): void { program .command("doctor") .description("Check git repo, optional GITHUB_TOKEN, and LLM credentials") - .action(() => { + .action(async () => { const cwd = process.cwd(); const { config } = loadConfig(cwd); let failed = false; @@ -54,6 +92,26 @@ export function registerDoctorCommand(program: Command): void { ); if (!gitOk) failed = true; + if (gitOk) { + const latestTag = await getLatestTag(cwd); + console.log( + `${latestTag ? pc.green("✓") : pc.yellow("!")} Latest tag: ${ + latestTag ?? "none detected" + }`, + ); + + try { + const range = await resolveRange(cwd); + console.log( + `${pc.green("✓")} Suggested range: ${range.fromRef}..${range.toRef}`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.log(`${pc.red("✗")} Suggested range: ${message}`); + failed = true; + } + } + const github = checkGitHubToken(); console.log( `${github.ok ? pc.green("✓") : pc.yellow("!")} GitHub: ${github.message}`, @@ -72,6 +130,12 @@ export function registerDoctorCommand(program: Command): void { }`, ); + const changelog = checkChangelogWritable(cwd, config.changelogPath); + console.log( + `${changelog.ok ? pc.green("✓") : pc.red("✗")} Changelog: ${changelog.message}`, + ); + if (!changelog.ok) failed = true; + console.log(""); if (failed) { console.log(pc.red("Some checks failed.")); diff --git a/packages/cli/src/commands/draft.ts b/packages/cli/src/commands/draft.ts index 513e920..b0a7a2a 100644 --- a/packages/cli/src/commands/draft.ts +++ b/packages/cli/src/commands/draft.ts @@ -2,13 +2,11 @@ import { writeFileSync } from "node:fs"; import { resolve } from "node:path"; import type { Command } from "commander"; import pc from "picocolors"; -import { - collectSources, - createLLMClient, - generateDraft, - loadConfig, - resolveRange, -} from "@polyscribe/core"; +import { createLLMClient, generateDraft, loadConfig } from "@polyscribe/core"; +import { gatherSources } from "../lib/gather-sources.js"; +import { handleCommandError } from "../lib/handle-error.js"; +import { printSources } from "../lib/print-sources.js"; +import { EXIT_CONFIG } from "../util/exit-codes.js"; export interface DraftCommandOptions { from?: string; @@ -16,6 +14,8 @@ export interface DraftCommandOptions { tone?: string; output?: string; json?: boolean; + sourcesOnly?: boolean; + noLlm?: boolean; } export function registerDraftCommand(program: Command): void { @@ -28,89 +28,100 @@ export function registerDraftCommand(program: Command): void { "--tone ", "editorial tone: technical | developer-friendly | executive | community", ) - .option("--output ", "write markdown to file") + .option( + "--output ", + "write markdown to file (e.g. RELEASE.md for polyscribe publish)", + ) .option("--json", "output draft as JSON") + .option("--sources-only", "collect and print sources JSON, no LLM") + .option("--no-llm", "alias for --sources-only") .action(async (options: DraftCommandOptions) => { const cwd = process.cwd(); - const { config } = loadConfig(cwd); - const tone = (options.tone ?? config.tone) as typeof config.tone; - const range = await resolveRange( - cwd, - options.from, - options.to ?? "HEAD", - ); + try { + const { config } = loadConfig(cwd); + const skipLlm = options.sourcesOnly || options.noLlm; - console.log(pc.dim("Collecting sources...")); - const sources = await collectSources( - cwd, - { - fromRef: range.fromRef, - toRef: range.toRef, - }, - { - ignoreGlobs: config.ignoreGlobs, - maxDiffBytesPerFile: config.maxDiffBytesPerFile, - maxTotalDiffBytes: config.maxTotalDiffBytes, - githubToken: process.env.GITHUB_TOKEN, - }, - ); + console.error(pc.dim("Collecting sources...")); + const { range, sources } = await gatherSources(cwd, { + from: options.from, + to: options.to, + }); + + if (skipLlm) { + printSources(sources, { + pretty: true, + range, + }); + return; + } + + if (sources.length === 0) { + console.error( + pc.yellow( + `No sources found for range ${range.fromRef}..${range.toRef}`, + ), + ); + process.exit(EXIT_CONFIG); + } - if (sources.length === 0) { console.error( - pc.yellow( - `No sources found for range ${range.fromRef}..${range.toRef}`, + pc.dim( + `Found ${sources.length} source(s) in ${range.fromRef}..${range.toRef}`, ), ); - process.exit(1); - } - console.log( - pc.dim( - `Found ${sources.length} source(s) in ${range.fromRef}..${range.toRef}`, - ), - ); + const tone = (options.tone ?? config.tone) as typeof config.tone; + const llm = createLLMClient({ + provider: config.llm?.provider, + model: config.llm?.model, + }); - const llm = createLLMClient({ - provider: config.llm?.provider, - model: config.llm?.model, - }); + console.error( + pc.dim(`Generating draft with ${llm.provider}/${llm.model}...`), + ); - console.log(pc.dim(`Generating draft with ${llm.provider}/${llm.model}...`)); + const draft = await generateDraft( + sources, + { + tone, + repositoryId: "local", + range: { fromRef: range.fromRef, toRef: range.toRef }, + }, + llm, + ); - const draft = await generateDraft( - sources, - { - tone, - repositoryId: "local", - range: { fromRef: range.fromRef, toRef: range.toRef }, - }, - llm, - ); + if (options.json) { + const payload = JSON.stringify(draft, null, 2); + if (options.output) { + writeFileSync(resolve(cwd, options.output), payload, "utf8"); + console.log(pc.green(`Wrote JSON draft to ${options.output}`)); + } else { + console.log(payload); + } + return; + } - if (options.json) { - const payload = JSON.stringify(draft, null, 2); if (options.output) { - writeFileSync(resolve(cwd, options.output), payload, "utf8"); - console.log(pc.green(`Wrote JSON draft to ${options.output}`)); + writeFileSync(resolve(cwd, options.output), draft.markdown, "utf8"); + console.log(pc.green(`Wrote draft to ${options.output}`)); } else { - console.log(payload); + console.log(""); + console.log(draft.markdown); + console.log(""); + console.log( + pc.dim( + `Suggested semver: ${draft.suggestedSemver} (heuristic: ${draft.heuristicSemver})`, + ), + ); + console.log( + pc.dim( + "Tip: save for publish with polyscribe draft --output RELEASE.md", + ), + ); } - return; - } - - if (options.output) { - writeFileSync(resolve(cwd, options.output), draft.markdown, "utf8"); - console.log(pc.green(`Wrote draft to ${options.output}`)); - } else { - console.log(""); - console.log(draft.markdown); - console.log(""); - console.log( - pc.dim( - `Suggested semver: ${draft.suggestedSemver} (heuristic: ${draft.heuristicSemver})`, - ), - ); + } catch (error) { + handleCommandError(error); } }); } diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts new file mode 100644 index 0000000..2c07119 --- /dev/null +++ b/packages/cli/src/commands/publish.ts @@ -0,0 +1,38 @@ +import type { Command } from "commander"; +import pc from "picocolors"; +import { EXIT_CONFIG } from "../util/exit-codes.js"; + +export interface PublishCommandOptions { + version?: string; + notes?: string; + target?: string; +} + +export function registerPublishCommand(program: Command): void { + program + .command("publish") + .description("Create a GitHub Release from a version tag (Phase 1b)") + .option("--version ", "release version tag") + .option( + "--notes ", + "release notes markdown file (default: RELEASE.md)", + ) + .option( + "--target ", + "publish target: github-release | changelog-pr", + ) + .action((options: PublishCommandOptions) => { + console.error(pc.yellow("polyscribe publish is not yet implemented.")); + console.error( + pc.dim( + "Prepare release notes with: polyscribe draft --output RELEASE.md", + ), + ); + if (options.notes) { + console.error(pc.dim(`Notes file: ${options.notes}`)); + } else { + console.error(pc.dim("Default notes file: RELEASE.md")); + } + process.exit(EXIT_CONFIG); + }); +} diff --git a/packages/cli/src/commands/sources.ts b/packages/cli/src/commands/sources.ts new file mode 100644 index 0000000..71ee1df --- /dev/null +++ b/packages/cli/src/commands/sources.ts @@ -0,0 +1,43 @@ +import type { Command } from "commander"; +import pc from "picocolors"; +import { gatherSources } from "../lib/gather-sources.js"; +import { handleCommandError } from "../lib/handle-error.js"; +import { printSources } from "../lib/print-sources.js"; + +export interface SourcesCommandOptions { + from?: string; + to?: string; + json?: boolean; + pretty?: boolean; + count?: boolean; +} + +export function registerSourcesCommand(program: Command): void { + program + .command("sources") + .description("Collect and print ingestion sources as JSON (no LLM)") + .option("--from ", "start ref (default: latest tag or root commit)") + .option("--to ", "end ref (default: HEAD)") + .option("--json", "output SourceItem[] as JSON (default)") + .option("--pretty", "pretty-print JSON") + .option("--count", "only print source count summary") + .action(async (options: SourcesCommandOptions) => { + const cwd = process.cwd(); + + try { + console.error(pc.dim("Collecting sources...")); + const { range, sources } = await gatherSources(cwd, { + from: options.from, + to: options.to, + }); + + printSources(sources, { + pretty: options.pretty, + count: options.count, + range, + }); + } catch (error) { + handleCommandError(error); + } + }); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f9119f2..01635b3 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -8,6 +8,8 @@ import { registerChangelogCommand } from "./commands/changelog.js"; import { registerConfigCommand } from "./commands/config.js"; import { registerDoctorCommand } from "./commands/doctor.js"; import { registerDraftCommand } from "./commands/draft.js"; +import { registerPublishCommand } from "./commands/publish.js"; +import { registerSourcesCommand } from "./commands/sources.js"; import { registerValidateConfigCommand } from "./commands/validate-config.js"; const packageRoot = dirname(fileURLToPath(import.meta.url)); @@ -25,6 +27,8 @@ export function createProgram(): Command { registerDraftCommand(program); registerChangelogCommand(program); + registerSourcesCommand(program); + registerPublishCommand(program); registerConfigCommand(program); registerValidateConfigCommand(program); registerDoctorCommand(program); diff --git a/packages/cli/src/lib/gather-sources.ts b/packages/cli/src/lib/gather-sources.ts new file mode 100644 index 0000000..c1209b0 --- /dev/null +++ b/packages/cli/src/lib/gather-sources.ts @@ -0,0 +1,47 @@ +import { + collectSources, + loadConfig, + resolveRange, + type PolyScribeConfigFile, + type ResolvedRange, + type SourceItem, +} from "@polyscribe/core"; + +export interface GatherSourcesOptions { + from?: string; + to?: string; +} + +export interface GatherSourcesResult { + config: PolyScribeConfigFile; + range: ResolvedRange; + sources: SourceItem[]; +} + +export async function gatherSources( + cwd: string, + options: GatherSourcesOptions, +): Promise { + const { config } = loadConfig(cwd); + const range = await resolveRange( + cwd, + options.from, + options.to ?? "HEAD", + ); + + const sources = await collectSources( + cwd, + { + fromRef: range.fromRef, + toRef: range.toRef, + }, + { + ignoreGlobs: config.ignoreGlobs, + maxDiffBytesPerFile: config.maxDiffBytesPerFile, + maxTotalDiffBytes: config.maxTotalDiffBytes, + githubToken: process.env.GITHUB_TOKEN, + }, + ); + + return { config, range, sources }; +} diff --git a/packages/cli/src/lib/handle-error.ts b/packages/cli/src/lib/handle-error.ts new file mode 100644 index 0000000..da34c2d --- /dev/null +++ b/packages/cli/src/lib/handle-error.ts @@ -0,0 +1,66 @@ +import pc from "picocolors"; +import { CitationValidationError } from "@polyscribe/core"; +import { + EXIT_CITATION, + EXIT_CONFIG, + EXIT_GIT, + EXIT_LLM, +} from "../util/exit-codes.js"; + +function isExecaError( + error: unknown, +): error is Error & { shortMessage?: string; command?: string } { + return error instanceof Error && "shortMessage" in error; +} + +export function isConfigError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + return ( + error.message.includes("Invalid config") || + error.message.includes("expected a YAML mapping") || + error.message.includes("Invalid date format") + ); +} + +export function isGitError(error: unknown): boolean { + if (isExecaError(error)) { + return error.command === "git" || error.shortMessage?.includes("git") === true; + } + + return error instanceof Error && error.message.includes("git"); +} + +export function handleCommandError(error: unknown): never { + const message = error instanceof Error ? error.message : String(error); + + if (error instanceof CitationValidationError) { + console.error(pc.red(message)); + process.exit(EXIT_CITATION); + } + + if (isConfigError(error)) { + console.error(pc.red(`Config error: ${message}`)); + process.exit(EXIT_CONFIG); + } + + if (isGitError(error)) { + console.error(pc.red(`Git error: ${message}`)); + process.exit(EXIT_GIT); + } + + if ( + error instanceof Error && + (message.includes("API key") || + message.includes("LLM") || + message.includes("OpenAI") || + message.includes("Anthropic")) + ) { + console.error(pc.red(`LLM error: ${message}`)); + process.exit(EXIT_LLM); + } + + throw error; +} diff --git a/packages/cli/src/lib/print-sources.ts b/packages/cli/src/lib/print-sources.ts new file mode 100644 index 0000000..55d76f0 --- /dev/null +++ b/packages/cli/src/lib/print-sources.ts @@ -0,0 +1,26 @@ +import pc from "picocolors"; +import type { ResolvedRange, SourceItem } from "@polyscribe/core"; + +export interface PrintSourcesOptions { + pretty?: boolean; + count?: boolean; + range?: ResolvedRange; +} + +export function printSources( + sources: SourceItem[], + options: PrintSourcesOptions = {}, +): void { + if (options.count) { + const rangeLabel = options.range + ? ` in ${options.range.fromRef}..${options.range.toRef}` + : ""; + console.log(pc.dim(`Sources: ${sources.length}${rangeLabel}`)); + return; + } + + const payload = options.pretty + ? JSON.stringify(sources, null, 2) + : JSON.stringify(sources); + console.log(payload); +} diff --git a/packages/cli/src/util/exit-codes.ts b/packages/cli/src/util/exit-codes.ts new file mode 100644 index 0000000..4ae1d1a --- /dev/null +++ b/packages/cli/src/util/exit-codes.ts @@ -0,0 +1,5 @@ +export const EXIT_OK = 0; +export const EXIT_CONFIG = 1; +export const EXIT_GIT = 2; +export const EXIT_LLM = 3; +export const EXIT_CITATION = 4; diff --git a/packages/cli/src/util/format-date.ts b/packages/cli/src/util/format-date.ts new file mode 100644 index 0000000..ad2d5b6 --- /dev/null +++ b/packages/cli/src/util/format-date.ts @@ -0,0 +1,13 @@ +const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +export function formatDateUtc(date?: string): string { + if (date === undefined) { + return new Date().toISOString().slice(0, 10); + } + + if (!DATE_PATTERN.test(date)) { + throw new Error(`Invalid date format: ${date}. Expected YYYY-MM-DD`); + } + + return date; +} diff --git a/packages/cli/src/util/line-diff.ts b/packages/cli/src/util/line-diff.ts new file mode 100644 index 0000000..e8b2429 --- /dev/null +++ b/packages/cli/src/util/line-diff.ts @@ -0,0 +1,64 @@ +import pc from "picocolors"; + +function longestCommonSubsequence(a: string[], b: string[]): number[][] { + const rows = a.length + 1; + const cols = b.length + 1; + const table = Array.from({ length: rows }, () => Array(cols).fill(0)); + + for (let i = 1; i < rows; i++) { + for (let j = 1; j < cols; j++) { + if (a[i - 1] === b[j - 1]) { + table[i]![j] = table[i - 1]![j - 1]! + 1; + } else { + table[i]![j] = Math.max(table[i - 1]![j]!, table[i]![j - 1]!); + } + } + } + + return table; +} + +type DiffOp = + | { type: "equal"; line: string } + | { type: "remove"; line: string } + | { type: "add"; line: string }; + +function diffLines(before: string[], after: string[]): DiffOp[] { + const table = longestCommonSubsequence(before, after); + const ops: DiffOp[] = []; + let i = before.length; + let j = after.length; + + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && before[i - 1] === after[j - 1]) { + ops.push({ type: "equal", line: before[i - 1]! }); + i--; + j--; + } else if (j > 0 && (i === 0 || table[i]![j - 1]! >= table[i - 1]![j]!)) { + ops.push({ type: "add", line: after[j - 1]! }); + j--; + } else { + ops.push({ type: "remove", line: before[i - 1]! }); + i--; + } + } + + return ops.reverse(); +} + +export function renderLineDiff(before: string, after: string): string { + const ops = diffLines(before.split("\n"), after.split("\n")); + + return ops + .map((op) => { + switch (op.type) { + case "equal": + return pc.dim(` ${op.line}`); + case "remove": + return pc.red(`-${op.line}`); + case "add": + return pc.green(`+${op.line}`); + } + }) + .join("\n"); +} diff --git a/packages/cli/tests/changelog-dry-run.test.ts b/packages/cli/tests/changelog-dry-run.test.ts new file mode 100644 index 0000000..8ee2d67 --- /dev/null +++ b/packages/cli/tests/changelog-dry-run.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { formatDateUtc } from "../src/util/format-date.js"; +import { renderLineDiff } from "../src/util/line-diff.js"; + +describe("formatDateUtc", () => { + it("returns today in UTC when no date is provided", () => { + const today = new Date().toISOString().slice(0, 10); + expect(formatDateUtc()).toBe(today); + }); + + it("accepts a valid YYYY-MM-DD override", () => { + expect(formatDateUtc("2026-07-11")).toBe("2026-07-11"); + }); + + it("rejects invalid date formats", () => { + expect(() => formatDateUtc("07/11/2026")).toThrow( + "Invalid date format: 07/11/2026. Expected YYYY-MM-DD", + ); + }); +}); + +describe("renderLineDiff", () => { + it("marks added and removed lines", () => { + const before = "## [Unreleased]\n"; + const after = "## [Unreleased]\n\n## [1.0.0] - 2026-07-11\n- Added feature\n"; + const diff = renderLineDiff(before, after); + + expect(diff).toContain("+## [1.0.0] - 2026-07-11"); + expect(diff).toContain("+- Added feature"); + }); + + it("leaves unchanged lines unmarked", () => { + const before = "# Changelog\n\n## [Unreleased]\n"; + const after = "# Changelog\n\n## [Unreleased]\n\n## [1.0.0] - 2026-07-11\n"; + const diff = renderLineDiff(before, after); + + expect(diff).toContain("# Changelog"); + expect(diff).not.toContain("+# Changelog"); + }); +}); diff --git a/packages/cli/tests/sources.test.ts b/packages/cli/tests/sources.test.ts new file mode 100644 index 0000000..fc5492d --- /dev/null +++ b/packages/cli/tests/sources.test.ts @@ -0,0 +1,122 @@ +import { Command } from "commander"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { registerSourcesCommand } from "../src/commands/sources.js"; +import type { SourceItem } from "@polyscribe/core"; + +const mockSources: SourceItem[] = [ + { + id: "commit:abc123", + type: "commit", + sha: "abc123", + title: "feat: add sources command", + author: { login: "dev", id: "dev" }, + labels: [], + url: "commit:abc123", + }, +]; + +vi.mock("../src/lib/gather-sources.js", () => ({ + gatherSources: vi.fn(async () => ({ + config: { changelogPath: "CHANGELOG.md", tone: "developer-friendly" }, + range: { fromRef: "v1.0.0", toRef: "HEAD", fromSha: "aaa", toSha: "bbb" }, + sources: mockSources, + })), +})); + +vi.mock("../src/lib/handle-error.js", () => ({ + handleCommandError: vi.fn((error: unknown) => { + throw error; + }), +})); + +describe("sources command", () => { + const logs: string[] = []; + const errors: string[] = []; + + afterEach(() => { + logs.length = 0; + errors.length = 0; + vi.restoreAllMocks(); + }); + + function createProgram(): Command { + const program = new Command(); + registerSourcesCommand(program); + return program; + } + + function captureOutput(): void { + vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { + logs.push(args.map(String).join(" ")); + }); + vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => { + errors.push(args.map(String).join(" ")); + }); + } + + it("registers with expected options", () => { + const program = createProgram(); + const cmd = program.commands.find((command) => command.name() === "sources"); + + expect(cmd).toBeDefined(); + expect(cmd?.options.map((option) => option.flags)).toEqual( + expect.arrayContaining([ + "--from ", + "--to ", + "--json", + "--pretty", + "--count", + ]), + ); + }); + + it("prints JSON sources by default", async () => { + captureOutput(); + const program = createProgram(); + await program.parseAsync(["node", "polyscribe", "sources"]); + + expect(logs).toHaveLength(1); + expect(JSON.parse(logs[0]!)).toEqual(mockSources); + expect(errors.some((line) => line.includes("Collecting sources"))).toBe( + true, + ); + }); + + it("prints pretty JSON when --pretty is set", async () => { + captureOutput(); + const program = createProgram(); + await program.parseAsync(["node", "polyscribe", "sources", "--pretty"]); + + expect(logs[0]).toContain("\n"); + expect(logs[0]).toContain("feat: add sources command"); + }); + + it("prints count summary when --count is set", async () => { + captureOutput(); + const program = createProgram(); + await program.parseAsync(["node", "polyscribe", "sources", "--count"]); + + expect(logs[0]).toContain("Sources: 1"); + expect(logs[0]).toContain("v1.0.0..HEAD"); + }); + + it("forwards --from and --to to gatherSources", async () => { + const { gatherSources } = await import("../src/lib/gather-sources.js"); + captureOutput(); + const program = createProgram(); + await program.parseAsync([ + "node", + "polyscribe", + "sources", + "--from", + "v0.1.0", + "--to", + "main", + ]); + + expect(gatherSources).toHaveBeenCalledWith(process.cwd(), { + from: "v0.1.0", + to: "main", + }); + }); +}); diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 0000000..19384e8 --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["tests/**/*.test.ts"], + }, +}); From 82a9bd1abc5404baed20321daafd0610a3e10607 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 11 Jul 2026 13:17:14 +0000 Subject: [PATCH 5/7] feat(core): add citation/git tests, semver reasons, and Octokit hardening - Add citation validation tests and git integration fixture/tests - Return { level, reasons } from suggestSemverFromSources with conventional commit detection - Configure Octokit with retry/throttling plugins and fix createOctokit stub - Parse linked issues (Fixes #123) from PR bodies - Add PR parsing unit tests and update semver tests Co-authored-by: Moses Man --- .github/polyscribe.yml | 1 + .github/workflows/ci.yml | 45 ++++ .polyscribe.yml | 43 ++++ CHANGELOG.md | 25 ++ README.md | 84 ++++++- SPEC.md | 11 +- docs/cli.md | 253 ++++++++++++++++++++ docs/configuration.md | 216 +++++++++++++++++ docs/self-hosting.md | 47 ++++ packages/core/package.json | 35 ++- packages/core/src/draft/generate.ts | 3 +- packages/core/src/draft/llm/providers.ts | 1 - packages/core/src/github/client.ts | 29 ++- packages/core/src/github/index.ts | 15 ++ packages/core/src/github/prs.ts | 26 +- packages/core/src/github/releases.ts | 144 +++++++++++ packages/core/src/semver/heuristics.ts | 95 ++++++-- packages/core/src/semver/index.ts | 6 +- packages/core/src/sources/index.ts | 8 + packages/core/src/types.ts | 20 ++ packages/core/tests/citation.test.ts | 84 +++++++ packages/core/tests/fixtures/git-repo.ts | 56 +++++ packages/core/tests/git.integration.test.ts | 58 +++++ packages/core/tests/prs.test.ts | 40 ++++ packages/core/tests/releases.test.ts | 178 ++++++++++++++ packages/core/tests/semver.test.ts | 51 +++- pnpm-lock.yaml | 48 ++++ 27 files changed, 1577 insertions(+), 45 deletions(-) create mode 120000 .github/polyscribe.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .polyscribe.yml create mode 100644 CHANGELOG.md create mode 100644 docs/cli.md create mode 100644 docs/configuration.md create mode 100644 docs/self-hosting.md create mode 100644 packages/core/src/github/index.ts create mode 100644 packages/core/src/github/releases.ts create mode 100644 packages/core/tests/citation.test.ts create mode 100644 packages/core/tests/fixtures/git-repo.ts create mode 100644 packages/core/tests/git.integration.test.ts create mode 100644 packages/core/tests/prs.test.ts create mode 100644 packages/core/tests/releases.test.ts diff --git a/.github/polyscribe.yml b/.github/polyscribe.yml new file mode 120000 index 0000000..5d59148 --- /dev/null +++ b/.github/polyscribe.yml @@ -0,0 +1 @@ +../.polyscribe.yml \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..bf19171 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: + - main + - cursor/** + pull_request: + branches: + - main + - cursor/** + +jobs: + ci: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + + - name: Test + run: pnpm test + + - name: Typecheck + run: pnpm typecheck + + - name: Lint + run: pnpm lint diff --git a/.polyscribe.yml b/.polyscribe.yml new file mode 100644 index 0000000..9c36801 --- /dev/null +++ b/.polyscribe.yml @@ -0,0 +1,43 @@ +# PolyScribe configuration for this repository. +# See docs/configuration.md for the full reference. + +changelogPath: CHANGELOG.md +tone: developer-friendly + +# requireApprover: true +# autoPublish: false +# includeUnreleased: false + +# publishTargets: +# - github-release +# - changelog-pr + +# ignoreGlobs: +# - "**/package-lock.json" +# - "**/pnpm-lock.yaml" +# - "**/yarn.lock" +# - "**/bun.lockb" +# - "**/dist/**" +# - "**/generated/**" + +# monorepoRoots: [] +# includeCommittersWithoutPr: true +# maxDiffBytesPerFile: 20000 +# maxTotalDiffBytes: 400000 + +# sections: +# order: +# - summary +# - breaking +# - features +# - fixes +# - perf +# - security +# - docs +# - chore +# - migration +# - credits + +# llm: +# provider: openai +# model: gpt-4.1 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..547eadf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +All notable changes to PolyScribe will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.0] - 2026-07-11 + +### Added + +- Initial open-source release of `@polyscribe/core` and `@polyscribe/cli` +- Git-native source ingestion: commits, PR metadata (with `GITHUB_TOKEN`), and diffs +- AI-powered `draft` command with structured release note sections and semver heuristics +- `changelog` command with Keep a Changelog merge via `--write` +- Configuration via `.polyscribe.yml` and `.github/polyscribe.yml` (deep-merged) +- `config init`, `validate-config`, and `doctor` preflight commands +- OpenAI and Anthropic LLM providers with configurable models +- Secret redaction, diff size limits, and citation validation in the draft pipeline +- Monorepo scaffold (pnpm + Turborepo), unit tests, and GitHub Actions CI + +[Unreleased]: https://github.com/LatticeAG/PolyScribe/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/LatticeAG/PolyScribe/releases/tag/v0.1.0 diff --git a/README.md b/README.md index 16e8a6f..2751e6b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # PolyScribe +[![CI](https://github.com/LatticeAG/PolyScribe/actions/workflows/ci.yml/badge.svg)](https://github.com/LatticeAG/PolyScribe/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE) +[![npm @polyscribe/cli](https://img.shields.io/npm/v/@polyscribe/cli.svg)](https://www.npmjs.com/package/@polyscribe/cli) + PolyScribe is a GitHub-native release editor: it ingests commits, PRs, and diffs for a ref range, drafts polished release notes and changelog sections, and supports human approve → publish. ## OSS-first @@ -8,31 +12,93 @@ This repository is **open-source first**. `@polyscribe/core` and `@polyscribe/cl ## Quick start +### Install + ```bash -# From this repo (development) -pnpm install && pnpm build -node packages/cli/dist/index.js config --init +# npm (after publish) +npm install -g @polyscribe/cli + +# or run without installing +npx @polyscribe/cli --help +``` + +### One-time setup -# With npm (after publish) -npx @polyscribe/cli config --init +```bash +polyscribe config init export OPENAI_API_KEY=sk-... # or ANTHROPIC_API_KEY export GITHUB_TOKEN=ghp_... # optional — enriches with PR metadata +``` + +### Preflight +```bash polyscribe doctor +polyscribe validate-config +``` + +### Draft release notes + +```bash +polyscribe draft --from v0.1.0 --to HEAD polyscribe draft --from v0.1.0 --to HEAD --output RELEASE.md +polyscribe draft --tone technical --json --output draft.json +``` + +### Update changelog + +```bash +polyscribe changelog --from v0.1.0 --to HEAD polyscribe changelog --version 0.2.0 --write ``` +### Debug sources (coming in Phase 1b) + +```bash +# polyscribe sources --from v0.1.0 --to HEAD --json +``` + +### Publish to GitHub (coming in Phase 1b) + +```bash +# polyscribe publish --version 0.2.0 --notes RELEASE.md +``` + +### Development (this repo) + +```bash +pnpm install && pnpm build +node packages/cli/dist/index.js config --init +``` + +## Environment variables + +| Variable | Required | Purpose | +|----------|----------|---------| +| `OPENAI_API_KEY` | One of the LLM keys | OpenAI draft generation | +| `ANTHROPIC_API_KEY` | One of the LLM keys | Anthropic draft generation | +| `POLYSCRIBE_LLM_PROVIDER` | No | Force `openai` or `anthropic` | +| `GITHUB_TOKEN` | No | Enrich ingestion with PR metadata from GitHub | + ## Packages | Package | Description | |---------|-------------| -| `@polyscribe/core` | Core library — parsing, drafting, and release logic | -| `@polyscribe/cli` | Command-line interface (`polyscribe`) | +| [`@polyscribe/core`](./packages/core) | Core library — parsing, drafting, and release logic | +| [`@polyscribe/cli`](./packages/cli) | Command-line interface (`polyscribe`) | + +Published packages include a `prepublishOnly` script that runs `pnpm build` before npm publish. The root workspace package is private and is not published. + +## Documentation + +- [CLI reference](./docs/cli.md) — all commands, options, and exit codes +- [Configuration](./docs/configuration.md) — `.polyscribe.yml` schema +- [Self-hosting](./docs/self-hosting.md) — OSS CLI setup; server coming later +- [SPEC](./SPEC.md) — product and implementation specification ## Development -Requires **Node.js 22+** and **pnpm**. +Requires **Node.js 22+** and **pnpm 10+**. ```bash pnpm install @@ -42,6 +108,8 @@ pnpm lint pnpm typecheck ``` +CI runs the same checks on push and pull requests to `main` and `cursor/*`. + ## License MIT — see [LICENSE](./LICENSE). diff --git a/SPEC.md b/SPEC.md index d5fd673..6dd6c1d 100644 --- a/SPEC.md +++ b/SPEC.md @@ -440,13 +440,18 @@ sections: | `draft/` pipeline | ✅ Done | OpenAI + Anthropic | | CLI draft/changelog/config/doctor | ✅ Done | | | Unit tests (32) | ✅ Done | | +| GitHub Actions CI | ✅ Done | `.github/workflows/ci.yml` | +| `docs/cli.md` | ✅ Done | full CLI reference | +| `docs/configuration.md` | ✅ Done | `.polyscribe.yml` reference | +| `docs/self-hosting.md` | ✅ Done | OSS CLI note; server later | +| Root `CHANGELOG.md` + `.polyscribe.yml` | ✅ Done | Keep a Changelog + example config | +| npm publish prep (`@polyscribe/*` v0.1.0) | ✅ Done | metadata, `prepublishOnly`, public access | | `polyscribe publish` | ⬜ Phase 1b | GitHub Releases API | | `polyscribe sources` | ⬜ Phase 1b | debug JSON export | | Git integration tests | ⬜ Phase 1b | fixture repo | | Citation unit tests | ⬜ Phase 1b | | -| `docs/cli.md` | ⬜ Phase 1b | | | Dogfood on LatticeAG repo | ⬜ Phase 1b | | -| npm publish `@polyscribe/*` | ⬜ Phase 1c | | +| npm publish `@polyscribe/*` to registry | ⬜ Phase 1c | `pnpm publish` after tag | ### 0.1.9 OSS milestones (active) @@ -458,7 +463,7 @@ sections: | **O4** Changelog | `insertVersion` + CLI `changelog --write` | ✅ | | **O5** Tests | ≥30 unit tests, no live LLM | ✅ | | **O6** Publish | `polyscribe publish` creates GitHub Release | ⬜ | -| **O7** Docs + npm | README quickstart + npm publish | ⬜ | +| **O7** Docs + npm | README quickstart + npm publish prep | 🟡 Docs/CI done; registry publish Phase 1c | | **O8** Dogfood | PolyScribe release notes via PolyScribe | ⬜ | ### 0.1.10 Phase 1b recommendations (next build tasks) diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..b6c6ff5 --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,253 @@ +# CLI reference + +PolyScribe ships as `@polyscribe/cli` with the `polyscribe` binary. Run `polyscribe --help` for a summary. + +## Global options + +| Option | Description | +|--------|-------------| +| `-V, --version` | Print CLI version | +| `-h, --help` | Show help | + +## Environment + +PolyScribe reads credentials from the environment. See [configuration](./configuration.md) for `.polyscribe.yml` settings. + +| Variable | Required | Purpose | +|----------|----------|---------| +| `OPENAI_API_KEY` | One of the LLM keys | OpenAI draft generation | +| `ANTHROPIC_API_KEY` | One of the LLM keys | Anthropic draft generation | +| `POLYSCRIBE_LLM_PROVIDER` | No | Force `openai` or `anthropic` | +| `GITHUB_TOKEN` | No | Enrich ingestion with PR metadata from GitHub | + +## `polyscribe draft` + +Generate AI release notes from git history for a ref range. + +```bash +polyscribe draft [options] +``` + +### Options + +| Option | Description | +|--------|-------------| +| `--from ` | Start ref (default: latest tag, or root commit if none) | +| `--to ` | End ref (default: `HEAD`) | +| `--tone ` | Override config tone: `technical`, `developer-friendly`, `executive`, `community` | +| `--output ` | Write output to a file | +| `--json` | Output structured draft JSON instead of markdown | + +### Behavior + +1. Loads `.polyscribe.yml` / `.github/polyscribe.yml` (see [configuration](./configuration.md)). +2. Resolves the git ref range. +3. Collects sources (commits, PRs, diffs) with optional GitHub enrichment. +4. Calls the configured LLM to produce structured sections and rendered markdown. + +### Examples + +```bash +polyscribe draft --from v0.1.0 --to HEAD +polyscribe draft --from v0.1.0 --to HEAD --output RELEASE.md +polyscribe draft --tone technical --json --output draft.json +``` + +### Exit codes + +| Code | Meaning | +|------|---------| +| `0` | Success | +| `1` | No sources, config error, or usage error | + +--- + +## `polyscribe changelog` + +Generate a Keep a Changelog body for a ref range and optionally merge it into your changelog file. + +```bash +polyscribe changelog [options] +``` + +### Options + +| Option | Description | +|--------|-------------| +| `--from ` | Start ref | +| `--to ` | End ref (default: `HEAD`) | +| `--version ` | Version heading (required with `--write`) | +| `--write` | Update `changelogPath` from config (default: `CHANGELOG.md`) | + +### Behavior + +1. Collects sources and generates a draft (same pipeline as `draft`). +2. Renders sections in Keep a Changelog format. +3. Without `--write`, prints the body to stdout. +4. With `--write`, inserts a new `## [version] - YYYY-MM-DD` section under `## [Unreleased]`. + +### Examples + +```bash +polyscribe changelog --from v0.1.0 --to HEAD +polyscribe changelog --version 0.2.0 --write +``` + +--- + +## `polyscribe publish` + +> **Status:** Planned for Phase 1b — not yet implemented in v0.1.0. + +Create a GitHub Release from approved release notes. + +```bash +polyscribe publish [options] +``` + +### Planned options + +| Option | Description | +|--------|-------------| +| `--version ` | Release version / tag | +| `--notes ` | Markdown body (default: generated or stdin) | +| `--target ` | Publish target from config (`github-release`, `changelog-pr`) | + +### Planned behavior + +- Requires `GITHUB_TOKEN` with `repo` scope. +- Creates a release via `POST /repos/{owner}/{repo}/releases` with `tag_name`, `body`, and `draft: false`. +- Expects the git tag to already exist. + +--- + +## `polyscribe sources` + +> **Status:** Planned for Phase 1b — not yet implemented in v0.1.0. + +Print collected ingestion sources as JSON for debugging (no LLM calls). + +```bash +polyscribe sources [options] +``` + +### Planned options + +| Option | Description | +|--------|-------------| +| `--from ` | Start ref | +| `--to ` | End ref (default: `HEAD`) | +| `--json` | Output JSON (default format) | + +Useful for inspecting what commits, PRs, and diffs PolyScribe would send to the LLM before spending API credits. + +--- + +## `polyscribe config` + +Manage PolyScribe configuration files. + +### `polyscribe config init` + +Write an example `.polyscribe.yml` in the current directory. + +```bash +polyscribe config init [--path ] +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `--path ` | `.polyscribe.yml` | Destination path | + +### Example + +```bash +polyscribe config init +polyscribe config init --path .github/polyscribe.yml +``` + +--- + +## `polyscribe validate-config` + +Validate a config file against the PolyScribe schema. + +```bash +polyscribe validate-config [path] +``` + +| Argument | Description | +|----------|-------------| +| `[path]` | Optional path to a config file. If omitted, searches `.polyscribe.yml` and `.github/polyscribe.yml`. | + +### Exit codes + +| Code | Meaning | +|------|---------| +| `0` | Valid config | +| `1` | Missing or invalid config | + +### Example + +```bash +polyscribe validate-config +polyscribe validate-config .polyscribe.yml +``` + +--- + +## `polyscribe doctor` + +Run preflight checks before drafting or publishing. + +```bash +polyscribe doctor +``` + +### Checks + +| Check | Required | +|-------|----------| +| Current directory is a git repository | Yes | +| `GITHUB_TOKEN` is set | No (warns if missing) | +| LLM API key available (`OPENAI_API_KEY` or `ANTHROPIC_API_KEY`) | Yes | +| Config file present | No (uses defaults if absent) | + +### Exit codes + +| Code | Meaning | +|------|---------| +| `0` | All required checks passed | +| `1` | One or more required checks failed | + +### Example + +```bash +polyscribe doctor +``` + +--- + +## Typical workflow + +```bash +# One-time setup +polyscribe config init +export OPENAI_API_KEY=sk-... +export GITHUB_TOKEN=ghp_... # optional + +# Preflight +polyscribe doctor +polyscribe validate-config + +# Draft release notes +polyscribe draft --from v0.1.0 --to HEAD --output RELEASE.md + +# Update changelog +polyscribe changelog --version 0.2.0 --write + +# Future: publish to GitHub +# polyscribe publish --version 0.2.0 --notes RELEASE.md +``` + +See also: [configuration](./configuration.md) · [self-hosting](./self-hosting.md) diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..7415279 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,216 @@ +# Configuration reference + +PolyScribe reads YAML configuration from your repository. Two locations are supported and **deep-merged** (later files override earlier ones): + +1. `.github/polyscribe.yml` — org/repo defaults (loaded first) +2. `.polyscribe.yml` — repository overrides (loaded second, wins on conflicts) + +If no file exists, sensible defaults apply. Run `polyscribe config init` to scaffold a starter file. + +Validate any file with: + +```bash +polyscribe validate-config +``` + +## Full schema + +```yaml +# Path to Keep a Changelog file (relative to repo root) +changelogPath: CHANGELOG.md + +# Editorial tone for generated release notes +# Values: technical | developer-friendly | executive | community +tone: developer-friendly + +# Glob patterns excluded from diff ingestion +ignoreGlobs: + - "**/package-lock.json" + - "**/pnpm-lock.yaml" + - "**/yarn.lock" + - "**/bun.lockb" + - "**/dist/**" + - "**/generated/**" + +# Monorepo package roots for scoped ingestion (future use) +monorepoRoots: [] + +# SaaS workflow flags (ignored by OSS CLI in v0.1.0) +requireApprover: true +autoPublish: false +includeUnreleased: false + +# Planned publish destinations (Phase 1b+) +publishTargets: + - github-release + - changelog-pr + +# Include commit authors not linked to a PR in the contributors section +includeCommittersWithoutPr: true + +# Cap diff size per file and total across the range +maxDiffBytesPerFile: 20000 +maxTotalDiffBytes: 400000 + +# Section ordering in rendered output +sections: + order: + - summary + - breaking + - features + - fixes + - perf + - security + - docs + - chore + - migration + - credits + +# LLM provider (CLI / self-host only) +llm: + provider: openai # openai | anthropic | openai-compatible + model: gpt-4.1 +``` + +## Field reference + +### `changelogPath` + +| Type | Default | +|------|---------| +| string | `CHANGELOG.md` | + +Relative path to the Keep a Changelog file updated by `polyscribe changelog --write`. + +### `tone` + +| Type | Default | +|------|---------| +| enum | `developer-friendly` | + +Controls prompt style for `draft` and `changelog`. Can be overridden per invocation with `polyscribe draft --tone`. + +| Value | Style | +|-------|-------| +| `developer-friendly` | Clear, concrete; explains user impact | +| `technical` | Precise API names, endpoints, config keys | +| `executive` | Short outcome-focused summary | +| `community` | Warm tone; emphasizes contributors | + +### `ignoreGlobs` + +| Type | Default | +|------|---------| +| string[] | lockfiles, `dist/**`, `generated/**` | + +Minimatch patterns. Matching files are omitted from diff ingestion. + +### `monorepoRoots` + +| Type | Default | +|------|---------| +| string[] | `[]` | + +Reserved for monorepo-aware ingestion (e.g. `packages/*`). Empty means whole-repo scope. + +### `requireApprover` + +| Type | Default | +|------|---------| +| boolean | `true` | + +Used by hosted PolyScribe for approval workflows. Ignored by the OSS CLI in v0.1.0. + +### `autoPublish` + +| Type | Default | +|------|---------| +| boolean | `false` | + +When true in hosted mode, approved drafts publish automatically. Ignored by OSS CLI. + +### `includeUnreleased` + +| Type | Default | +|------|---------| +| boolean | `false` | + +Include unreleased changelog sections when merging (hosted workflows). + +### `publishTargets` + +| Type | Default | +|------|---------| +| enum[] | `github-release`, `changelog-pr` | + +Destinations for `polyscribe publish` (Phase 1b). OSS v0.1.0 does not publish yet. + +### `includeCommittersWithoutPr` + +| Type | Default | +|------|---------| +| boolean | `true` | + +When true, commit authors without an associated PR appear in the Contributors section. + +### `maxDiffBytesPerFile` / `maxTotalDiffBytes` + +| Field | Type | Default | +|-------|------|---------| +| `maxDiffBytesPerFile` | positive integer | `20000` | +| `maxTotalDiffBytes` | positive integer | `400000` | + +Guardrails to keep LLM context bounded. Large diffs are truncated. + +### `sections.order` + +| Type | Default | +|------|---------| +| enum[] | see schema above | + +Order of sections in rendered markdown and Keep a Changelog output. Empty sections are omitted except Summary and Contributors when sources exist. + +### `llm` + +Optional block for CLI and self-hosted deployments. + +| Field | Type | Description | +|-------|------|-------------| +| `provider` | `openai` \| `anthropic` \| `openai-compatible` | LLM backend | +| `model` | string | Model ID (defaults: `gpt-4.1` for OpenAI, `claude-sonnet-4-20250514` for Anthropic) | + +Credentials are **not** stored in config. Set `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` in the environment. + +## Example: minimal config + +```yaml +changelogPath: CHANGELOG.md +tone: technical +llm: + provider: anthropic + model: claude-sonnet-4-20250514 +``` + +## Example: split org + repo config + +**.github/polyscribe.yml** (shared defaults): + +```yaml +tone: developer-friendly +ignoreGlobs: + - "**/dist/**" +llm: + provider: openai + model: gpt-4.1 +``` + +**.polyscribe.yml** (repo override): + +```yaml +tone: technical +changelogPath: docs/CHANGELOG.md +``` + +Merged result uses `tone: technical` and inherits `ignoreGlobs` and `llm` from `.github/polyscribe.yml`. + +See also: [CLI reference](./cli.md) · [self-hosting](./self-hosting.md) diff --git a/docs/self-hosting.md b/docs/self-hosting.md new file mode 100644 index 0000000..75f95d9 --- /dev/null +++ b/docs/self-hosting.md @@ -0,0 +1,47 @@ +# Self-hosting + +The PolyScribe **OSS CLI is self-contained**. Everything you need to draft and write changelogs runs locally with: + +- A git repository +- An LLM API key (`OPENAI_API_KEY` or `ANTHROPIC_API_KEY`) +- Optionally `GITHUB_TOKEN` for richer PR metadata + +There is no PolyScribe server to deploy in v0.1.0. Install from npm and run commands in your repo: + +```bash +npm install -g @polyscribe/cli +# or +npx @polyscribe/cli doctor +``` + +## What runs where + +| Component | v0.1.0 | Notes | +|-----------|--------|-------| +| `@polyscribe/cli` | ✅ Available | `polyscribe` binary | +| `@polyscribe/core` | ✅ Available | Library for custom integrations | +| Hosted SaaS | 🔜 Later | Approval workflows, team roles, managed LLM routing | +| Self-host server | 🔜 Later | Same core, HTTP API + optional UI | + +## Recommended setup + +1. Add `.polyscribe.yml` (or symlink `.github/polyscribe.yml` → root config). +2. Store `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` and `GITHUB_TOKEN` in your CI secrets or local shell profile — never commit them. +3. Run `polyscribe doctor` in CI or locally before release automation. + +Example GitHub Actions step (after secrets are configured): + +```yaml +- run: npx @polyscribe/cli draft --from ${{ github.event.release.tag_name }} --to HEAD --output RELEASE.md + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +## Future self-host server + +A dedicated PolyScribe server (REST API, webhooks, approval UI) is planned on top of `@polyscribe/core`. The OSS CLI will remain the offline-first path; the server will be optional for teams that want centralized review without SaaS. + +Track progress in [SPEC.md](../SPEC.md) and [CHANGELOG.md](../CHANGELOG.md). + +See also: [CLI reference](./cli.md) · [configuration](./configuration.md) diff --git a/packages/core/package.json b/packages/core/package.json index 1a56cb4..f4d3f7b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,32 @@ { "name": "@polyscribe/core", - "version": "0.0.0", + "version": "0.1.0", + "description": "Core library for PolyScribe — git ingestion, AI drafting, and changelog merging", "type": "module", + "license": "MIT", + "author": "LatticeAG", + "repository": { + "type": "git", + "url": "git+https://github.com/LatticeAG/PolyScribe.git", + "directory": "packages/core" + }, + "homepage": "https://github.com/LatticeAG/PolyScribe#readme", + "bugs": { + "url": "https://github.com/LatticeAG/PolyScribe/issues" + }, + "keywords": [ + "polyscribe", + "release-notes", + "changelog", + "github", + "semver", + "llm", + "openai", + "anthropic" + ], + "publishConfig": { + "access": "public" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -17,10 +42,13 @@ "build": "tsup", "test": "vitest run", "lint": "eslint src", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "prepublishOnly": "pnpm run build" }, "dependencies": { "@anthropic-ai/sdk": "^0.39.0", + "@octokit/plugin-retry": "^8.0.0", + "@octokit/plugin-throttling": "^10.0.0", "@octokit/rest": "^22.0.0", "execa": "^9.6.0", "minimatch": "^10.0.3", @@ -33,5 +61,8 @@ "tsup": "^8.5.0", "typescript": "^5.8.3", "vitest": "^3.2.4" + }, + "engines": { + "node": ">=22" } } diff --git a/packages/core/src/draft/generate.ts b/packages/core/src/draft/generate.ts index 39be742..bd07dbc 100644 --- a/packages/core/src/draft/generate.ts +++ b/packages/core/src/draft/generate.ts @@ -102,7 +102,8 @@ export async function generateDraft( } const tone = config.tone ?? "developer-friendly"; - const heuristicSemver = suggestSemverFromSources(sources); + const semverSuggestion = suggestSemverFromSources(sources); + const heuristicSemver = semverSuggestion.level; const prompts = buildDraftPrompts(sources, tone, heuristicSemver); const sourceIdSet = new Set(sources.map((s) => s.id)); diff --git a/packages/core/src/draft/llm/providers.ts b/packages/core/src/draft/llm/providers.ts index 5b638e0..d55a605 100644 --- a/packages/core/src/draft/llm/providers.ts +++ b/packages/core/src/draft/llm/providers.ts @@ -1,6 +1,5 @@ import OpenAI from "openai"; import Anthropic from "@anthropic-ai/sdk"; -import type { ZodSchema } from "zod"; import { zodToJsonSchema } from "../zod-to-json-schema.js"; import type { LLMClient, LLMCompleteInput } from "./client.js"; diff --git a/packages/core/src/github/client.ts b/packages/core/src/github/client.ts index eb48efd..bf5e92c 100644 --- a/packages/core/src/github/client.ts +++ b/packages/core/src/github/client.ts @@ -1,6 +1,11 @@ +import { Octokit } from "@octokit/rest"; +import { retry } from "@octokit/plugin-retry"; +import { throttling } from "@octokit/plugin-throttling"; import type { RemoteRepo } from "../types.js"; import { tryGit } from "../git/exec.js"; +const OctokitWithPlugins = Octokit.plugin(retry, throttling) as typeof Octokit; + export function parseRemoteUrl(url: string): RemoteRepo | null { const sshMatch = url.match(/^git@([^:]+):([^/]+)\/(.+?)(?:\.git)?$/); if (sshMatch) { @@ -37,7 +42,27 @@ export async function getCommitDate( return result.ok ? result.stdout.trim() : null; } -export function createOctokit(token?: string) { +export function createOctokit(token?: string): Octokit | null { if (!token) return null; - return { auth: token }; + + return new OctokitWithPlugins({ + auth: token, + throttle: { + onRateLimit: (retryAfter, options, octokit, retryCount) => { + octokit.log.warn( + `Rate limit hit for ${options.method} ${options.url}; retrying after ${retryAfter}s`, + ); + return retryCount < 2; + }, + onSecondaryRateLimit: (retryAfter, options, octokit, retryCount) => { + octokit.log.warn( + `Secondary rate limit for ${options.method} ${options.url}; retrying after ${retryAfter}s`, + ); + return retryCount < 2; + }, + }, + retry: { + doNotRetry: [400, 401, 404, 422], + }, + }); } diff --git a/packages/core/src/github/index.ts b/packages/core/src/github/index.ts new file mode 100644 index 0000000..ca7e67d --- /dev/null +++ b/packages/core/src/github/index.ts @@ -0,0 +1,15 @@ +export { + createOctokit, + detectRemoteRepo, + getCommitDate, + parseRemoteUrl, +} from "./client.js"; +export { fetchMergedPrsInRange, mapPrToSourceItem } from "./prs.js"; +export { + checkTagExists, + createGitHubRelease, + findReleaseByTag, + getReleaseByTag, + tagExists, + updateGitHubRelease, +} from "./releases.js"; diff --git a/packages/core/src/github/prs.ts b/packages/core/src/github/prs.ts index e3f8ddf..b480e87 100644 --- a/packages/core/src/github/prs.ts +++ b/packages/core/src/github/prs.ts @@ -1,5 +1,24 @@ -import { Octokit } from "@octokit/rest"; import type { RemoteRepo, SourceItem } from "../types.js"; +import { createOctokit } from "./client.js"; + +const LINKED_ISSUE_PATTERN = + /\b(?:fix(?:e[sd])?|close[sd]?|resolve[sd]?)\s+#(\d+)\b/gi; + +export function parseLinkedIssues( + body?: string | null, +): Array<{ number: number; title: string }> { + if (!body) return []; + + const issues = new Map(); + + for (const match of body.matchAll(LINKED_ISSUE_PATTERN)) { + const number = Number(match[1]); + if (!Number.isFinite(number)) continue; + issues.set(number, { number, title: `Issue #${number}` }); + } + + return [...issues.values()]; +} export function mapPrToSourceItem( pr: { @@ -31,6 +50,7 @@ export function mapPrToSourceItem( }, mergedAt: pr.merged_at ?? undefined, labels, + linkedIssues: parseLinkedIssues(pr.body), url: pr.html_url ?? (remote @@ -49,7 +69,9 @@ export async function fetchMergedPrsInRange( return []; } - const octokit = new Octokit({ auth: token }); + const octokit = createOctokit(token); + if (!octokit) return []; + const items: SourceItem[] = []; let page = 1; diff --git a/packages/core/src/github/releases.ts b/packages/core/src/github/releases.ts new file mode 100644 index 0000000..bb09a13 --- /dev/null +++ b/packages/core/src/github/releases.ts @@ -0,0 +1,144 @@ +import type { Octokit } from "@octokit/rest"; +import type { PublishReleaseOptions, PublishReleaseResult } from "../types.js"; +import { createOctokit } from "./client.js"; + +export async function tagExists( + octokit: Octokit, + owner: string, + repo: string, + tag: string, +): Promise { + try { + await octokit.git.getRef({ + owner, + repo, + ref: `tags/${tag}`, + }); + return true; + } catch (error) { + if ( + error && + typeof error === "object" && + "status" in error && + error.status === 404 + ) { + return false; + } + throw error; + } +} + +export async function getReleaseByTag( + octokit: Octokit, + owner: string, + repo: string, + tag: string, +): Promise<{ id: number; html_url: string; tag_name: string; draft: boolean; prerelease: boolean } | null> { + try { + const response = await octokit.repos.getReleaseByTag({ + owner, + repo, + tag, + }); + return response.data; + } catch (error) { + if ( + error && + typeof error === "object" && + "status" in error && + error.status === 404 + ) { + return null; + } + throw error; + } +} + +function mapReleaseResult( + release: { + id: number; + html_url: string; + tag_name: string; + draft: boolean; + prerelease: boolean; + }, + created: boolean, +): PublishReleaseResult { + return { + id: release.id, + htmlUrl: release.html_url, + tagName: release.tag_name, + draft: release.draft, + prerelease: release.prerelease, + created, + }; +} + +export async function createGitHubRelease( + options: PublishReleaseOptions, + octokit?: Octokit, +): Promise { + const client = octokit ?? createOctokit(options.token); + if (!client) { + throw new Error("GitHub token is required to create a release"); + } + const response = await client.repos.createRelease({ + owner: options.owner, + repo: options.repo, + tag_name: options.tag, + name: options.title, + body: options.body, + draft: options.draft ?? false, + prerelease: options.prerelease ?? false, + }); + + return mapReleaseResult(response.data, true); +} + +export async function updateGitHubRelease( + options: PublishReleaseOptions & { releaseId: number }, + octokit?: Octokit, +): Promise { + const client = octokit ?? createOctokit(options.token); + if (!client) { + throw new Error("GitHub token is required to update a release"); + } + const response = await client.repos.updateRelease({ + owner: options.owner, + repo: options.repo, + release_id: options.releaseId, + tag_name: options.tag, + name: options.title, + body: options.body, + draft: options.draft ?? false, + prerelease: options.prerelease ?? false, + }); + + return mapReleaseResult(response.data, false); +} + +export async function checkTagExists( + token: string, + owner: string, + repo: string, + tag: string, +): Promise { + const octokit = createOctokit(token); + if (!octokit) { + throw new Error("GitHub token is required to check tag existence"); + } + return tagExists(octokit, owner, repo, tag); +} + +export async function findReleaseByTag( + token: string, + owner: string, + repo: string, + tag: string, +): Promise<{ id: number; html_url: string; tag_name: string; draft: boolean; prerelease: boolean } | null> { + const octokit = createOctokit(token); + if (!octokit) { + throw new Error("GitHub token is required to find a release"); + } + return getReleaseByTag(octokit, owner, repo, tag); +} diff --git a/packages/core/src/semver/heuristics.ts b/packages/core/src/semver/heuristics.ts index c5e5cf5..4838324 100644 --- a/packages/core/src/semver/heuristics.ts +++ b/packages/core/src/semver/heuristics.ts @@ -3,30 +3,87 @@ import type { SourceItem } from "../types.js"; const BREAKING_LABELS = new Set(["breaking", "breaking-change"]); const FEAT_LABELS = new Set(["feat", "feature", "enhancement"]); -export function suggestSemverFromSources( - sources: SourceItem[], -): "patch" | "minor" | "major" { +export type SemverLevel = "patch" | "minor" | "major"; + +export interface SemverSuggestion { + level: SemverLevel; + reasons: string[]; +} + +const CONVENTIONAL_MAJOR = /^[a-z]+!\s*[:(]/i; +const CONVENTIONAL_FEAT = /^feat(\(|:)/i; +const CONVENTIONAL_PATCH_TYPES = /^(fix|chore|docs)(\(|:)/i; + +function collectMajorReasons(source: SourceItem): string[] { + const reasons: string[] = []; + const labels = source.labels.map((l) => l.toLowerCase()); + const titleBody = `${source.title}\n${source.body ?? ""}`; + + if (labels.some((l) => BREAKING_LABELS.has(l))) { + reasons.push(`breaking label on ${source.id}`); + } + if (/BREAKING CHANGE/i.test(titleBody)) { + reasons.push(`BREAKING CHANGE in ${source.id}`); + } + if (CONVENTIONAL_MAJOR.test(source.title)) { + reasons.push(`conventional breaking commit: ${source.title}`); + } + + return reasons; +} + +function collectMinorReasons(source: SourceItem): string[] { + const reasons: string[] = []; + const labels = source.labels.map((l) => l.toLowerCase()); + + if (labels.some((l) => FEAT_LABELS.has(l))) { + reasons.push(`feature label on ${source.id}`); + } + if (CONVENTIONAL_FEAT.test(source.title) && !CONVENTIONAL_MAJOR.test(source.title)) { + reasons.push(`conventional feat commit: ${source.title}`); + } + + return reasons; +} + +function collectPatchReasons(source: SourceItem): string[] { + const reasons: string[] = []; + + if (CONVENTIONAL_PATCH_TYPES.test(source.title)) { + reasons.push(`conventional patch commit: ${source.title}`); + } + + return reasons; +} + +export function suggestSemverFromSources(sources: SourceItem[]): SemverSuggestion { + const majorReasons: string[] = []; + const minorReasons: string[] = []; + const patchReasons: string[] = []; + for (const source of sources) { - const labels = source.labels.map((l) => l.toLowerCase()); - const titleBody = `${source.title}\n${source.body ?? ""}`; + majorReasons.push(...collectMajorReasons(source)); + } - if ( - labels.some((l) => BREAKING_LABELS.has(l)) || - /BREAKING CHANGE/i.test(titleBody) - ) { - return "major"; - } + if (majorReasons.length > 0) { + return { level: "major", reasons: majorReasons }; } for (const source of sources) { - const labels = source.labels.map((l) => l.toLowerCase()); - if ( - labels.some((l) => FEAT_LABELS.has(l)) || - /^feat(\(|:)/i.test(source.title) - ) { - return "minor"; - } + minorReasons.push(...collectMinorReasons(source)); + } + + if (minorReasons.length > 0) { + return { level: "minor", reasons: minorReasons }; + } + + for (const source of sources) { + patchReasons.push(...collectPatchReasons(source)); + } + + if (patchReasons.length > 0) { + return { level: "patch", reasons: patchReasons }; } - return "patch"; + return { level: "patch", reasons: ["no semver signals; defaulting to patch"] }; } diff --git a/packages/core/src/semver/index.ts b/packages/core/src/semver/index.ts index 2b39ca5..558a96c 100644 --- a/packages/core/src/semver/index.ts +++ b/packages/core/src/semver/index.ts @@ -1 +1,5 @@ -export { suggestSemverFromSources } from "./heuristics.js"; +export { + suggestSemverFromSources, + type SemverLevel, + type SemverSuggestion, +} from "./heuristics.js"; diff --git a/packages/core/src/sources/index.ts b/packages/core/src/sources/index.ts index 2762ae0..93180b7 100644 --- a/packages/core/src/sources/index.ts +++ b/packages/core/src/sources/index.ts @@ -10,6 +10,14 @@ export { parseRemoteUrl, } from "../github/client.js"; export { fetchMergedPrsInRange, mapPrToSourceItem } from "../github/prs.js"; +export { + checkTagExists, + createGitHubRelease, + findReleaseByTag, + getReleaseByTag, + tagExists, + updateGitHubRelease, +} from "../github/releases.js"; export { applyIgnoreGlobs, shouldIgnorePath } from "../ingest/filter.js"; export { collectSources } from "../ingest/collect.js"; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index bbef081..93ba8c5 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -129,3 +129,23 @@ export interface GenerateDraftConfig { repositoryId?: string; range?: ReleaseRange; } + +export interface PublishReleaseOptions { + token: string; + owner: string; + repo: string; + tag: string; + title: string; + body: string; + draft?: boolean; + prerelease?: boolean; +} + +export interface PublishReleaseResult { + id: number; + htmlUrl: string; + tagName: string; + draft: boolean; + prerelease: boolean; + created: boolean; +} diff --git a/packages/core/tests/citation.test.ts b/packages/core/tests/citation.test.ts new file mode 100644 index 0000000..59615dd --- /dev/null +++ b/packages/core/tests/citation.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { + CitationValidationError, + validateSectionCitations, +} from "../src/draft/generate.js"; +import type { DraftSection } from "../src/types.js"; + +function makeSection( + overrides: Partial & Pick, +): DraftSection { + return { + title: overrides.type, + content: "", + sourceIds: [], + ...overrides, + }; +} + +describe("validateSectionCitations", () => { + const sourceIds = new Set(["pr:1", "commit:abc"]); + + it("passes for valid sections with known sourceIds", () => { + const sections: DraftSection[] = [ + makeSection({ + type: "features", + content: "- Added webhook retries", + sourceIds: ["pr:1"], + }), + makeSection({ + type: "fixes", + content: "- Fixed pagination (#42)", + sourceIds: ["commit:abc"], + }), + ]; + + expect(validateSectionCitations(sections, sourceIds)).toEqual([]); + }); + + it("fails for unknown sourceId", () => { + const sections: DraftSection[] = [ + makeSection({ + type: "features", + content: "- Added webhook retries", + sourceIds: ["pr:1", "pr:999"], + }), + ]; + + const invalid = validateSectionCitations(sections, sourceIds); + + expect(invalid).toEqual([ + { + type: "features", + reason: "unknown sourceId: pr:999", + }, + ]); + }); + + it("allows empty credits section without sourceIds", () => { + const sections: DraftSection[] = [ + makeSection({ + type: "credits", + content: "", + sourceIds: [], + }), + ]; + + expect(validateSectionCitations(sections, sourceIds)).toEqual([]); + }); +}); + +describe("CitationValidationError", () => { + it("exposes invalid section details", () => { + const invalid = [{ type: "features", reason: "unknown sourceId: pr:999" }]; + const error = new CitationValidationError( + "Draft failed citation validation (1 issue(s))", + invalid, + ); + + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe("CitationValidationError"); + expect(error.message).toContain("citation validation"); + expect(error.invalidSections).toEqual(invalid); + }); +}); diff --git a/packages/core/tests/fixtures/git-repo.ts b/packages/core/tests/fixtures/git-repo.ts new file mode 100644 index 0000000..ac7b5a9 --- /dev/null +++ b/packages/core/tests/fixtures/git-repo.ts @@ -0,0 +1,56 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +export interface GitFixture { + cwd: string; + tagSha: string; + headSha: string; + tag: string; +} + +export function isGitAvailable(): boolean { + try { + execFileSync("git", ["--version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +export function createGitFixture(): GitFixture { + const cwd = mkdtempSync(join(tmpdir(), "polyscribe-git-fixture-")); + const run = (args: string[]) => { + execFileSync("git", args, { cwd, stdio: "pipe" }); + }; + + run(["init"]); + run(["config", "user.email", "test@polyscribe.dev"]); + run(["config", "user.name", "PolyScribe Test"]); + run(["config", "commit.gpgsign", "false"]); + + writeFileSync(join(cwd, "README.md"), "# Fixture\n"); + run(["add", "README.md"]); + run(["commit", "-m", "chore: initial commit"]); + + writeFileSync(join(cwd, "README.md"), "# Fixture\n\nFirst release.\n"); + run(["add", "README.md"]); + run(["commit", "-m", "feat: first feature"]); + const tagSha = execFileSync("git", ["rev-parse", "HEAD"], { + cwd, + encoding: "utf8", + }).trim(); + run(["tag", "v0.1.0"]); + + mkdirSync(join(cwd, "src"), { recursive: true }); + writeFileSync(join(cwd, "src", "index.ts"), "export const version = '0.1.1';\n"); + run(["add", "src/index.ts"]); + run(["commit", "-m", "fix: patch after tag"]); + const headSha = execFileSync("git", ["rev-parse", "HEAD"], { + cwd, + encoding: "utf8", + }).trim(); + + return { cwd, tagSha, headSha, tag: "v0.1.0" }; +} diff --git a/packages/core/tests/git.integration.test.ts b/packages/core/tests/git.integration.test.ts new file mode 100644 index 0000000..830d4a2 --- /dev/null +++ b/packages/core/tests/git.integration.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, beforeAll } from "vitest"; +import { getFileChanges } from "../src/git/diff.js"; +import { getCommitsInRange } from "../src/git/log.js"; +import { resolveRange } from "../src/git/refs.js"; +import { + createGitFixture, + isGitAvailable, + type GitFixture, +} from "./fixtures/git-repo.js"; + +const gitAvailable = isGitAvailable(); + +describe.skipIf(!gitAvailable)("git integration", () => { + let fixture: GitFixture | undefined; + + beforeAll(() => { + if (!gitAvailable) return; + fixture = createGitFixture(); + }, 30_000); + + it("resolveRange defaults to tag..HEAD", async () => { + const resolved = await resolveRange(fixture!.cwd); + + expect(resolved.fromRef).toBe(fixture!.tag); + expect(resolved.toRef).toBe("HEAD"); + expect(resolved.fromSha).toBe(fixture!.tagSha); + expect(resolved.toSha).toBe(fixture!.headSha); + }); + + it("getCommitsInRange returns commits after the tag", async () => { + const resolved = await resolveRange(fixture!.cwd); + const commits = await getCommitsInRange( + fixture!.cwd, + resolved.fromSha, + resolved.toSha, + ); + + expect(commits).toHaveLength(1); + expect(commits[0]?.title).toBe("fix: patch after tag"); + }); + + it("getFileChanges returns file changes in range", async () => { + const resolved = await resolveRange(fixture!.cwd); + const changes = await getFileChanges( + fixture!.cwd, + resolved.fromSha, + resolved.toSha, + { + ignoreGlobs: [], + maxDiffBytesPerFile: 20_000, + maxTotalDiffBytes: 400_000, + }, + ); + + expect(changes.length).toBeGreaterThan(0); + expect(changes.some((change) => change.path === "src/index.ts")).toBe(true); + }); +}); diff --git a/packages/core/tests/prs.test.ts b/packages/core/tests/prs.test.ts new file mode 100644 index 0000000..66424a8 --- /dev/null +++ b/packages/core/tests/prs.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { mapPrToSourceItem, parseLinkedIssues } from "../src/github/prs.js"; + +describe("parseLinkedIssues", () => { + it("parses Fixes #123 from PR body", () => { + const issues = parseLinkedIssues("This PR fixes a bug.\n\nFixes #123"); + + expect(issues).toEqual([{ number: 123, title: "Issue #123" }]); + }); + + it("parses multiple closing keywords", () => { + const issues = parseLinkedIssues("Fixes #10\nCloses #20\nResolves #30"); + + expect(issues.map((issue) => issue.number)).toEqual([10, 20, 30]); + }); + + it("deduplicates repeated issue references", () => { + const issues = parseLinkedIssues("Fixes #42\nAlso fixes #42"); + + expect(issues).toEqual([{ number: 42, title: "Issue #42" }]); + }); +}); + +describe("mapPrToSourceItem", () => { + it("includes linkedIssues parsed from body", () => { + const item = mapPrToSourceItem({ + number: 55, + title: "fix: handle retries", + body: "Fixes #123 and closes #456", + user: { login: "alice", id: 1 }, + merged_at: "2026-01-01T00:00:00Z", + labels: [{ name: "fix" }], + }); + + expect(item.linkedIssues).toEqual([ + { number: 123, title: "Issue #123" }, + { number: 456, title: "Issue #456" }, + ]); + }); +}); diff --git a/packages/core/tests/releases.test.ts b/packages/core/tests/releases.test.ts new file mode 100644 index 0000000..e08292a --- /dev/null +++ b/packages/core/tests/releases.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Octokit } from "@octokit/rest"; +import { + createGitHubRelease, + getReleaseByTag, + tagExists, + updateGitHubRelease, +} from "../src/github/releases.js"; + +function createMockOctokit(handlers: { + getRef?: ReturnType; + getReleaseByTag?: ReturnType; + createRelease?: ReturnType; + updateRelease?: ReturnType; +}): Octokit { + return { + git: { + getRef: handlers.getRef ?? vi.fn(), + }, + repos: { + getReleaseByTag: handlers.getReleaseByTag ?? vi.fn(), + createRelease: handlers.createRelease ?? vi.fn(), + updateRelease: handlers.updateRelease ?? vi.fn(), + }, + } as unknown as Octokit; +} + +describe("tagExists", () => { + it("returns true when the tag ref exists", async () => { + const getRef = vi.fn().mockResolvedValue({ data: { ref: "refs/tags/v1.0.0" } }); + const octokit = createMockOctokit({ getRef }); + + await expect(tagExists(octokit, "acme", "demo", "v1.0.0")).resolves.toBe(true); + expect(getRef).toHaveBeenCalledWith({ + owner: "acme", + repo: "demo", + ref: "tags/v1.0.0", + }); + }); + + it("returns false when the tag ref is missing", async () => { + const getRef = vi.fn().mockRejectedValue({ status: 404 }); + const octokit = createMockOctokit({ getRef }); + + await expect(tagExists(octokit, "acme", "demo", "v1.0.0")).resolves.toBe(false); + }); + + it("rethrows non-404 errors", async () => { + const getRef = vi.fn().mockRejectedValue({ status: 500, message: "server error" }); + const octokit = createMockOctokit({ getRef }); + + await expect(tagExists(octokit, "acme", "demo", "v1.0.0")).rejects.toEqual({ + status: 500, + message: "server error", + }); + }); +}); + +describe("getReleaseByTag", () => { + it("returns release data when found", async () => { + const release = { + id: 42, + html_url: "https://github.com/acme/demo/releases/tag/v1.0.0", + tag_name: "v1.0.0", + draft: false, + prerelease: false, + }; + const getReleaseByTagMock = vi.fn().mockResolvedValue({ data: release }); + const octokit = createMockOctokit({ getReleaseByTag: getReleaseByTagMock }); + + await expect( + getReleaseByTag(octokit, "acme", "demo", "v1.0.0"), + ).resolves.toEqual(release); + }); + + it("returns null when release is missing", async () => { + const getReleaseByTagMock = vi.fn().mockRejectedValue({ status: 404 }); + const octokit = createMockOctokit({ getReleaseByTag: getReleaseByTagMock }); + + await expect( + getReleaseByTag(octokit, "acme", "demo", "v1.0.0"), + ).resolves.toBeNull(); + }); +}); + +describe("createGitHubRelease", () => { + it("creates a release and maps the result", async () => { + const createRelease = vi.fn().mockResolvedValue({ + data: { + id: 7, + html_url: "https://github.com/acme/demo/releases/tag/v2.0.0", + tag_name: "v2.0.0", + draft: true, + prerelease: false, + }, + }); + const octokit = createMockOctokit({ createRelease }); + + const result = await createGitHubRelease( + { + token: "ghp_test", + owner: "acme", + repo: "demo", + tag: "v2.0.0", + title: "v2.0.0", + body: "## Changes\n- Initial release", + draft: true, + }, + octokit, + ); + + expect(createRelease).toHaveBeenCalledWith({ + owner: "acme", + repo: "demo", + tag_name: "v2.0.0", + name: "v2.0.0", + body: "## Changes\n- Initial release", + draft: true, + prerelease: false, + }); + expect(result).toEqual({ + id: 7, + htmlUrl: "https://github.com/acme/demo/releases/tag/v2.0.0", + tagName: "v2.0.0", + draft: true, + prerelease: false, + created: true, + }); + }); +}); + +describe("updateGitHubRelease", () => { + it("updates a release and maps the result", async () => { + const updateRelease = vi.fn().mockResolvedValue({ + data: { + id: 7, + html_url: "https://github.com/acme/demo/releases/tag/v2.0.0", + tag_name: "v2.0.0", + draft: false, + prerelease: true, + }, + }); + const octokit = createMockOctokit({ updateRelease }); + + const result = await updateGitHubRelease( + { + token: "ghp_test", + owner: "acme", + repo: "demo", + tag: "v2.0.0", + title: "Release 2.0", + body: "Updated notes", + prerelease: true, + releaseId: 7, + }, + octokit, + ); + + expect(updateRelease).toHaveBeenCalledWith({ + owner: "acme", + repo: "demo", + release_id: 7, + tag_name: "v2.0.0", + name: "Release 2.0", + body: "Updated notes", + draft: false, + prerelease: true, + }); + expect(result).toEqual({ + id: 7, + htmlUrl: "https://github.com/acme/demo/releases/tag/v2.0.0", + tagName: "v2.0.0", + draft: false, + prerelease: true, + created: false, + }); + }); +}); diff --git a/packages/core/tests/semver.test.ts b/packages/core/tests/semver.test.ts index 0e8a87a..b23a796 100644 --- a/packages/core/tests/semver.test.ts +++ b/packages/core/tests/semver.test.ts @@ -22,7 +22,23 @@ describe("suggestSemverFromSources", () => { }), ]; - expect(suggestSemverFromSources(sources)).toBe("patch"); + const result = suggestSemverFromSources(sources); + expect(result.level).toBe("patch"); + expect(result.reasons.some((reason) => reason.includes("fix:"))).toBe(true); + }); + + it("suggests patch for chore and docs conventional commits", () => { + const chore = suggestSemverFromSources([ + makeSource({ id: "commit:1", title: "chore: update dependencies" }), + ]); + const docs = suggestSemverFromSources([ + makeSource({ id: "commit:2", title: "docs: clarify setup steps" }), + ]); + + expect(chore.level).toBe("patch"); + expect(chore.reasons.some((reason) => reason.includes("chore:"))).toBe(true); + expect(docs.level).toBe("patch"); + expect(docs.reasons.some((reason) => reason.includes("docs:"))).toBe(true); }); it("suggests minor for feature labels", () => { @@ -33,7 +49,9 @@ describe("suggestSemverFromSources", () => { }), ]; - expect(suggestSemverFromSources(sources)).toBe("minor"); + const result = suggestSemverFromSources(sources); + expect(result.level).toBe("minor"); + expect(result.reasons.length).toBeGreaterThan(0); }); it("suggests minor for conventional feat commits", () => { @@ -44,7 +62,9 @@ describe("suggestSemverFromSources", () => { }), ]; - expect(suggestSemverFromSources(sources)).toBe("minor"); + const result = suggestSemverFromSources(sources); + expect(result.level).toBe("minor"); + expect(result.reasons.some((reason) => reason.includes("feat"))).toBe(true); }); it("suggests major for breaking labels", () => { @@ -55,7 +75,9 @@ describe("suggestSemverFromSources", () => { }), ]; - expect(suggestSemverFromSources(sources)).toBe("major"); + const result = suggestSemverFromSources(sources); + expect(result.level).toBe("major"); + expect(result.reasons.some((reason) => reason.includes("breaking"))).toBe(true); }); it("suggests major when BREAKING CHANGE appears in body", () => { @@ -67,7 +89,23 @@ describe("suggestSemverFromSources", () => { }), ]; - expect(suggestSemverFromSources(sources)).toBe("major"); + const result = suggestSemverFromSources(sources); + expect(result.level).toBe("major"); + expect(result.reasons.some((reason) => reason.includes("BREAKING CHANGE"))).toBe(true); + }); + + it("suggests major for feat! conventional commits", () => { + const sources = [ + makeSource({ + title: "feat!: remove deprecated client", + labels: ["feat"], + body: "Adds new client while removing the old one.", + }), + ]; + + const result = suggestSemverFromSources(sources); + expect(result.level).toBe("major"); + expect(result.reasons.some((reason) => reason.includes("breaking commit"))).toBe(true); }); it("prioritizes major over minor when both signals exist", () => { @@ -79,6 +117,7 @@ describe("suggestSemverFromSources", () => { }), ]; - expect(suggestSemverFromSources(sources)).toBe("major"); + const result = suggestSemverFromSources(sources); + expect(result.level).toBe("major"); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5889d76..9f678b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -54,6 +54,12 @@ importers: '@anthropic-ai/sdk': specifier: ^0.39.0 version: 0.39.0 + '@octokit/plugin-retry': + specifier: ^8.0.0 + version: 8.1.0(@octokit/core@7.0.6) + '@octokit/plugin-throttling': + specifier: ^10.0.0 + version: 10.0.0(@octokit/core@7.0.6) '@octokit/rest': specifier: ^22.0.0 version: 22.0.1 @@ -490,6 +496,9 @@ packages: resolution: {integrity: sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==} engines: {node: '>= 20'} + '@octokit/openapi-types@25.1.0': + resolution: {integrity: sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==} + '@octokit/openapi-types@27.0.0': resolution: {integrity: sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==} @@ -511,6 +520,18 @@ packages: peerDependencies: '@octokit/core': '>=6' + '@octokit/plugin-retry@8.1.0': + resolution: {integrity: sha512-O1FZgXeiGb2sowEr/hYTr6YunGdSAFWnr2fyW39Ah85H8O33ELASQxcvOFF5LE6Tjekcyu2ms4qAzJVhSaJxTw==} + engines: {node: '>= 20'} + peerDependencies: + '@octokit/core': '>=7' + + '@octokit/plugin-throttling@10.0.0': + resolution: {integrity: sha512-Kuq5/qs0DVYTHZuBAzCZStCzo2nKvVRo/TDNhCcpC2TKiOGz/DisXMCvjt3/b5kr6SCI1Y8eeeJTHBxxpFvZEg==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': ^6.1.3 + '@octokit/request-error@7.1.0': resolution: {integrity: sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==} engines: {node: '>= 20'} @@ -523,6 +544,9 @@ packages: resolution: {integrity: sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==} engines: {node: '>= 20'} + '@octokit/types@14.1.0': + resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==} + '@octokit/types@16.0.0': resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==} @@ -845,6 +869,9 @@ packages: before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + bottleneck@2.19.5: + resolution: {integrity: sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==} + brace-expansion@1.1.16: resolution: {integrity: sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==} @@ -1953,6 +1980,8 @@ snapshots: '@octokit/types': 16.0.0 universal-user-agent: 7.0.3 + '@octokit/openapi-types@25.1.0': {} + '@octokit/openapi-types@27.0.0': {} '@octokit/plugin-paginate-rest@14.0.0(@octokit/core@7.0.6)': @@ -1969,6 +1998,19 @@ snapshots: '@octokit/core': 7.0.6 '@octokit/types': 16.0.0 + '@octokit/plugin-retry@8.1.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/request-error': 7.1.0 + '@octokit/types': 16.0.0 + bottleneck: 2.19.5 + + '@octokit/plugin-throttling@10.0.0(@octokit/core@7.0.6)': + dependencies: + '@octokit/core': 7.0.6 + '@octokit/types': 14.1.0 + bottleneck: 2.19.5 + '@octokit/request-error@7.1.0': dependencies: '@octokit/types': 16.0.0 @@ -1989,6 +2031,10 @@ snapshots: '@octokit/plugin-request-log': 6.0.0(@octokit/core@7.0.6) '@octokit/plugin-rest-endpoint-methods': 17.0.0(@octokit/core@7.0.6) + '@octokit/types@14.1.0': + dependencies: + '@octokit/openapi-types': 25.1.0 + '@octokit/types@16.0.0': dependencies: '@octokit/openapi-types': 27.0.0 @@ -2286,6 +2332,8 @@ snapshots: before-after-hook@4.0.0: {} + bottleneck@2.19.5: {} + brace-expansion@1.1.16: dependencies: balanced-match: 1.0.2 From bbc345f9abe55fd1187ec200ecc76a49c0dc169d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 11 Jul 2026 13:17:51 +0000 Subject: [PATCH 6/7] docs: update publish/sources CLI reference and SPEC tracker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Document fully implemented polyscribe publish command - Mark O6, sources, git/citation tests done in SPEC §0.1.8 - Expand README quickstart with publish, dry-run, and sources Co-authored-by: Moses Man --- README.md | 15 ++-- SPEC.md | 18 ++-- docs/cli.md | 100 +++++++++++++++------- packages/cli/src/commands/publish.ts | 120 ++++++++++++++++++++++----- 4 files changed, 187 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 2751e6b..2d28f6e 100644 --- a/README.md +++ b/README.md @@ -50,18 +50,23 @@ polyscribe draft --tone technical --json --output draft.json ```bash polyscribe changelog --from v0.1.0 --to HEAD polyscribe changelog --version 0.2.0 --write +polyscribe changelog --version 0.2.0 --dry-run +polyscribe changelog --version 0.2.0 --write --notes RELEASE.md ``` -### Debug sources (coming in Phase 1b) +### Debug sources ```bash -# polyscribe sources --from v0.1.0 --to HEAD --json +polyscribe sources --from v0.1.0 --to HEAD +polyscribe sources --from v0.1.0 --to HEAD --pretty +polyscribe sources --count ``` -### Publish to GitHub (coming in Phase 1b) +### Publish to GitHub ```bash -# polyscribe publish --version 0.2.0 --notes RELEASE.md +polyscribe publish --version v0.2.0 --notes RELEASE.md +polyscribe publish --version v0.2.0 --notes RELEASE.md --update ``` ### Development (this repo) @@ -78,7 +83,7 @@ node packages/cli/dist/index.js config --init | `OPENAI_API_KEY` | One of the LLM keys | OpenAI draft generation | | `ANTHROPIC_API_KEY` | One of the LLM keys | Anthropic draft generation | | `POLYSCRIBE_LLM_PROVIDER` | No | Force `openai` or `anthropic` | -| `GITHUB_TOKEN` | No | Enrich ingestion with PR metadata from GitHub | +| `GITHUB_TOKEN` | No (required for `publish`) | Enrich ingestion with PR metadata; create GitHub Releases | ## Packages diff --git a/SPEC.md b/SPEC.md index 6dd6c1d..084213f 100644 --- a/SPEC.md +++ b/SPEC.md @@ -306,8 +306,8 @@ sources → redactSecrets → buildDraftPrompts → LLM structured JSON → vali | `polyscribe config --init` | **Implemented** | Write example `.polyscribe.yml` | | `polyscribe validate-config` | **Implemented** | Validate config file | | `polyscribe doctor` | **Implemented** | Preflight checks | -| `polyscribe publish` | **Phase 1b** | Create GitHub Release via API | -| `polyscribe sources` | **Phase 1b** | Print collected sources as JSON (debug) | +| `polyscribe publish` | **Implemented** | Create GitHub Release via API | +| `polyscribe sources` | **Implemented** | Print collected sources as JSON (debug) | #### `polyscribe draft` (spec) @@ -439,17 +439,17 @@ sections: | `redact/secrets` | ✅ Done | | | `draft/` pipeline | ✅ Done | OpenAI + Anthropic | | CLI draft/changelog/config/doctor | ✅ Done | | -| Unit tests (32) | ✅ Done | | +| Unit tests (52+) | ✅ Done | core + cli | | GitHub Actions CI | ✅ Done | `.github/workflows/ci.yml` | | `docs/cli.md` | ✅ Done | full CLI reference | | `docs/configuration.md` | ✅ Done | `.polyscribe.yml` reference | | `docs/self-hosting.md` | ✅ Done | OSS CLI note; server later | | Root `CHANGELOG.md` + `.polyscribe.yml` | ✅ Done | Keep a Changelog + example config | | npm publish prep (`@polyscribe/*` v0.1.0) | ✅ Done | metadata, `prepublishOnly`, public access | -| `polyscribe publish` | ⬜ Phase 1b | GitHub Releases API | -| `polyscribe sources` | ⬜ Phase 1b | debug JSON export | -| Git integration tests | ⬜ Phase 1b | fixture repo | -| Citation unit tests | ⬜ Phase 1b | | +| `polyscribe publish` | ✅ Done | GitHub Releases API | +| `polyscribe sources` | ✅ Done | debug JSON export | +| Git integration tests | ✅ Done | fixture repo | +| Citation unit tests | ✅ Done | | | Dogfood on LatticeAG repo | ⬜ Phase 1b | | | npm publish `@polyscribe/*` to registry | ⬜ Phase 1c | `pnpm publish` after tag | @@ -462,7 +462,7 @@ sections: | **O3** Draft | `generateDraft` + CLI `draft` | ✅ | | **O4** Changelog | `insertVersion` + CLI `changelog --write` | ✅ | | **O5** Tests | ≥30 unit tests, no live LLM | ✅ | -| **O6** Publish | `polyscribe publish` creates GitHub Release | ⬜ | +| **O6** Publish | `polyscribe publish` creates GitHub Release | ✅ | | **O7** Docs + npm | README quickstart + npm publish prep | 🟡 Docs/CI done; registry publish Phase 1c | | **O8** Dogfood | PolyScribe release notes via PolyScribe | ⬜ | @@ -1405,7 +1405,7 @@ Dogfood rule: **PolyScribe releases are written with PolyScribe** once M3 exists | O3 | Draft generation + CLI `draft` | ✅ | | O4 | Changelog merge + CLI `changelog` | ✅ | | O5 | Unit tests (32+) | ✅ | -| O6 | `polyscribe publish` | ⬜ Phase 1b | +| O6 | `polyscribe publish` | ✅ Done | | O7 | npm publish + docs | ⬜ Phase 1c | | O8 | Dogfood release | ⬜ Phase 1c | diff --git a/docs/cli.md b/docs/cli.md index b6c6ff5..0d71527 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -76,70 +76,109 @@ polyscribe changelog [options] |--------|-------------| | `--from ` | Start ref | | `--to ` | End ref (default: `HEAD`) | -| `--version ` | Version heading (required with `--write`) | +| `--version ` | Version heading (required with `--write` or `--dry-run`) | | `--write` | Update `changelogPath` from config (default: `CHANGELOG.md`) | +| `--dry-run` | Show unified diff of changelog change without writing | +| `--date ` | Override release date (default: today UTC) | +| `--notes ` | Use existing markdown as body (skips LLM) | ### Behavior 1. Collects sources and generates a draft (same pipeline as `draft`). 2. Renders sections in Keep a Changelog format. -3. Without `--write`, prints the body to stdout. +3. Without `--write` or `--dry-run`, prints the body to stdout. 4. With `--write`, inserts a new `## [version] - YYYY-MM-DD` section under `## [Unreleased]`. +5. With `--dry-run`, prints a line diff of the file change without writing. +6. With `--notes`, uses an existing markdown file as the body and skips the LLM. ### Examples ```bash polyscribe changelog --from v0.1.0 --to HEAD polyscribe changelog --version 0.2.0 --write +polyscribe changelog --version 0.2.0 --dry-run +polyscribe changelog --version 0.2.0 --write --notes RELEASE.md ``` --- -## `polyscribe publish` - -> **Status:** Planned for Phase 1b — not yet implemented in v0.1.0. +## `polyscribe sources` -Create a GitHub Release from approved release notes. +Collect and print ingestion sources as JSON without calling an LLM. Useful for debugging what PolyScribe would analyze before spending API credits. ```bash -polyscribe publish [options] +polyscribe sources [options] ``` -### Planned options +### Options | Option | Description | |--------|-------------| -| `--version ` | Release version / tag | -| `--notes ` | Markdown body (default: generated or stdin) | -| `--target ` | Publish target from config (`github-release`, `changelog-pr`) | +| `--from ` | Start ref (default: latest tag, or root commit if none) | +| `--to ` | End ref (default: `HEAD`) | +| `--json` | Output `SourceItem[]` as JSON (default) | +| `--pretty` | Pretty-print JSON | +| `--count` | Print only a source count summary | -### Planned behavior +### Examples -- Requires `GITHUB_TOKEN` with `repo` scope. -- Creates a release via `POST /repos/{owner}/{repo}/releases` with `tag_name`, `body`, and `draft: false`. -- Expects the git tag to already exist. +```bash +polyscribe sources --from v0.1.0 --to HEAD +polyscribe sources --pretty +polyscribe sources --count +``` ---- +Progress messages go to stderr; JSON output goes to stdout. -## `polyscribe sources` +--- -> **Status:** Planned for Phase 1b — not yet implemented in v0.1.0. +## `polyscribe publish` -Print collected ingestion sources as JSON for debugging (no LLM calls). +Create or update a GitHub Release for an existing git tag. ```bash -polyscribe sources [options] +polyscribe publish --version --notes [options] ``` -### Planned options +### Options -| Option | Description | -|--------|-------------| -| `--from ` | Start ref | -| `--to ` | End ref (default: `HEAD`) | -| `--json` | Output JSON (default format) | +| Option | Required | Description | +|--------|----------|-------------| +| `--version ` | Yes | Tag name (e.g. `v1.0.0`) — must exist on GitHub | +| `--notes ` | Yes | Markdown release notes file | +| `--title ` | No | Release title (default: tag name) | +| `--draft` | No | Create as draft release | +| `--prerelease` | No | Mark as prerelease | +| `--update` | No | Update existing release instead of failing | + +### Requirements + +- `GITHUB_TOKEN` with `repo` scope +- `origin` remote pointing at `github.com` +- Tag must already exist on the remote + +### Behavior + +1. Detects `origin` remote and validates it is GitHub. +2. Verifies the tag exists via the GitHub API. +3. Creates a release, or updates an existing one with `--update`. + +### Examples + +```bash +polyscribe draft --from v0.1.0 --to HEAD --output RELEASE.md +git tag v0.2.0 && git push origin v0.2.0 +polyscribe publish --version v0.2.0 --notes RELEASE.md +polyscribe publish --version v0.2.0 --notes RELEASE.md --update +``` -Useful for inspecting what commits, PRs, and diffs PolyScribe would send to the LLM before spending API credits. +### Exit codes + +| Code | Meaning | +|------|---------| +| `0` | Success | +| `1` | Config, usage, or missing token/remote/notes | +| `2` | Tag does not exist on remote | --- @@ -246,8 +285,11 @@ polyscribe draft --from v0.1.0 --to HEAD --output RELEASE.md # Update changelog polyscribe changelog --version 0.2.0 --write -# Future: publish to GitHub -# polyscribe publish --version 0.2.0 --notes RELEASE.md +# Inspect sources (no LLM) +polyscribe sources --from v0.1.0 --to HEAD + +# Publish to GitHub +polyscribe publish --version v0.2.0 --notes RELEASE.md ``` See also: [configuration](./configuration.md) · [self-hosting](./self-hosting.md) diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index 2c07119..dbea47e 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -1,38 +1,112 @@ +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; import type { Command } from "commander"; import pc from "picocolors"; -import { EXIT_CONFIG } from "../util/exit-codes.js"; +import { + checkTagExists, + createGitHubRelease, + detectRemoteRepo, + findReleaseByTag, + updateGitHubRelease, +} from "@polyscribe/core"; +import { EXIT_CONFIG, EXIT_GIT } from "../util/exit-codes.js"; export interface PublishCommandOptions { version?: string; notes?: string; - target?: string; + title?: string; + draft?: boolean; + prerelease?: boolean; + update?: boolean; } export function registerPublishCommand(program: Command): void { program .command("publish") - .description("Create a GitHub Release from a version tag (Phase 1b)") - .option("--version <version>", "release version tag") - .option( - "--notes <file>", - "release notes markdown file (default: RELEASE.md)", - ) - .option( - "--target <target>", - "publish target: github-release | changelog-pr", - ) - .action((options: PublishCommandOptions) => { - console.error(pc.yellow("polyscribe publish is not yet implemented.")); - console.error( - pc.dim( - "Prepare release notes with: polyscribe draft --output RELEASE.md", - ), + .description("Create or update a GitHub Release for an existing tag") + .requiredOption("--version <tag>", "tag name, e.g. v1.0.0") + .requiredOption("--notes <file>", "release notes markdown file") + .option("--title <title>", "release title (default: version)") + .option("--draft", "create as draft release") + .option("--prerelease", "mark as prerelease") + .option("--update", "update existing release instead of failing") + .action(async (options: PublishCommandOptions) => { + const cwd = process.cwd(); + const token = process.env.GITHUB_TOKEN; + + if (!token) { + console.error(pc.red("GITHUB_TOKEN is required for publish")); + process.exit(EXIT_CONFIG); + } + + const remote = await detectRemoteRepo(cwd); + if (!remote) { + console.error(pc.red("Could not detect git remote origin")); + process.exit(EXIT_CONFIG); + } + + if (remote.host !== "github.com") { + console.error( + pc.red(`Publish only supports github.com remotes (found ${remote.host})`), + ); + process.exit(EXIT_CONFIG); + } + + const notesPath = resolve(cwd, options.notes!); + if (!existsSync(notesPath)) { + console.error(pc.red(`Release notes file not found: ${options.notes}`)); + process.exit(EXIT_CONFIG); + } + + const body = readFileSync(notesPath, "utf8"); + const tag = options.version!; + const title = options.title ?? tag; + + const tagPresent = await checkTagExists(token, remote.owner, remote.repo, tag); + if (!tagPresent) { + console.error( + pc.yellow( + `Tag ${tag} does not exist on ${remote.owner}/${remote.repo}. Create the tag first, then publish.`, + ), + ); + process.exit(EXIT_GIT); + } + + const existing = await findReleaseByTag( + token, + remote.owner, + remote.repo, + tag, ); - if (options.notes) { - console.error(pc.dim(`Notes file: ${options.notes}`)); - } else { - console.error(pc.dim("Default notes file: RELEASE.md")); + + if (existing && !options.update) { + console.error( + pc.red( + `Release already exists for ${tag}. Use --update to update it.`, + ), + ); + process.exit(EXIT_CONFIG); } - process.exit(EXIT_CONFIG); + + const releaseOptions = { + token, + owner: remote.owner, + repo: remote.repo, + tag, + title, + body, + draft: options.draft, + prerelease: options.prerelease, + }; + + const result = existing + ? await updateGitHubRelease({ + ...releaseOptions, + releaseId: existing.id, + }) + : await createGitHubRelease(releaseOptions); + + const action = result.created ? "Created" : "Updated"; + console.log(pc.green(`${action} release: ${result.htmlUrl}`)); }); } From 4b87ac13fcb7a478ed783e776bfbd91980cad984 Mon Sep 17 00:00:00 2001 From: Cursor Agent <cursoragent@cursor.com> Date: Sat, 11 Jul 2026 13:19:50 +0000 Subject: [PATCH 7/7] feat(oss): citation retry, unreleased changelog, publish defaults - Retry LLM once on citation validation failure with repair prompt - Add changelog --unreleased to update [Unreleased] section only - Default publish --notes to RELEASE.md with helpful error message - Add updateUnreleased merge helper and generate-retry test (65 tests) - Update CHANGELOG, SPEC tracker, and CLI docs Co-authored-by: Moses Man <mosesman831@users.noreply.github.com> --- CHANGELOG.md | 15 ++++ SPEC.md | 7 +- docs/cli.md | 7 +- packages/cli/src/commands/changelog.ts | 35 ++++++++++ packages/cli/src/commands/publish.ts | 14 +++- packages/core/src/changelog/index.ts | 2 +- packages/core/src/changelog/merge.ts | 20 ++++++ packages/core/src/draft/generate.ts | 33 +++++++-- packages/core/src/draft/prompt.ts | 16 +++++ packages/core/tests/changelog.test.ts | 17 ++++- packages/core/tests/generate-retry.test.ts | 81 ++++++++++++++++++++++ 11 files changed, 230 insertions(+), 17 deletions(-) create mode 100644 packages/core/tests/generate-retry.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 547eadf..51d51a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `polyscribe changelog --unreleased` to update only the `[Unreleased]` section +- Citation validation retry: one automatic LLM repair pass before failing +- `polyscribe publish` defaults `--notes` to `RELEASE.md` +- `polyscribe sources` command with `--json`, `--pretty`, and `--count` +- `polyscribe changelog --dry-run`, `--date`, and `--notes` (skip LLM) +- `polyscribe draft --sources-only` / `--no-llm` for ingestion-only output +- GitHub Releases API via `polyscribe publish` (`--update`, `--draft`, `--prerelease`) +- Semver heuristics with `{ level, reasons }` and conventional commit detection +- Linked issue parsing from PR bodies (`Fixes #123`, etc.) +- Octokit retry/throttling for GitHub API rate limits +- Git integration tests, citation tests, and release API tests (65 total) +- CI workflow, docs (`docs/cli.md`, `docs/configuration.md`), and npm publish metadata + ## [0.1.0] - 2026-07-11 ### Added diff --git a/SPEC.md b/SPEC.md index 084213f..3520d28 100644 --- a/SPEC.md +++ b/SPEC.md @@ -439,7 +439,7 @@ sections: | `redact/secrets` | ✅ Done | | | `draft/` pipeline | ✅ Done | OpenAI + Anthropic | | CLI draft/changelog/config/doctor | ✅ Done | | -| Unit tests (52+) | ✅ Done | core + cli | +| Unit tests (65) | ✅ Done | core + cli | | GitHub Actions CI | ✅ Done | `.github/workflows/ci.yml` | | `docs/cli.md` | ✅ Done | full CLI reference | | `docs/configuration.md` | ✅ Done | `.polyscribe.yml` reference | @@ -449,8 +449,9 @@ sections: | `polyscribe publish` | ✅ Done | GitHub Releases API | | `polyscribe sources` | ✅ Done | debug JSON export | | Git integration tests | ✅ Done | fixture repo | -| Citation unit tests | ✅ Done | | -| Dogfood on LatticeAG repo | ⬜ Phase 1b | | +| Citation retry on validation fail | ✅ Done | one repair pass | +| `changelog --unreleased` | ✅ Done | update Unreleased section | +| Dogfood on LatticeAG repo | ⬜ Phase 1c | | | npm publish `@polyscribe/*` to registry | ⬜ Phase 1c | `pnpm publish` after tag | ### 0.1.9 OSS milestones (active) diff --git a/docs/cli.md b/docs/cli.md index 0d71527..8a39125 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -81,6 +81,7 @@ polyscribe changelog [options] | `--dry-run` | Show unified diff of changelog change without writing | | `--date <YYYY-MM-DD>` | Override release date (default: today UTC) | | `--notes <file>` | Use existing markdown as body (skips LLM) | +| `--unreleased` | Update only `## [Unreleased]` (no `--version` required) | ### Behavior @@ -90,6 +91,7 @@ polyscribe changelog [options] 4. With `--write`, inserts a new `## [version] - YYYY-MM-DD` section under `## [Unreleased]`. 5. With `--dry-run`, prints a line diff of the file change without writing. 6. With `--notes`, uses an existing markdown file as the body and skips the LLM. +7. With `--unreleased`, replaces the `[Unreleased]` section body only. ### Examples @@ -98,6 +100,7 @@ polyscribe changelog --from v0.1.0 --to HEAD polyscribe changelog --version 0.2.0 --write polyscribe changelog --version 0.2.0 --dry-run polyscribe changelog --version 0.2.0 --write --notes RELEASE.md +polyscribe changelog --unreleased --write ``` --- @@ -137,7 +140,7 @@ Progress messages go to stderr; JSON output goes to stdout. Create or update a GitHub Release for an existing git tag. ```bash -polyscribe publish --version <tag> --notes <file> [options] +polyscribe publish --version <tag> [--notes <file>] [options] ``` ### Options @@ -145,7 +148,7 @@ polyscribe publish --version <tag> --notes <file> [options] | Option | Required | Description | |--------|----------|-------------| | `--version <tag>` | Yes | Tag name (e.g. `v1.0.0`) — must exist on GitHub | -| `--notes <file>` | Yes | Markdown release notes file | +| `--notes <file>` | No | Markdown release notes file (default: `RELEASE.md`) | | `--title <title>` | No | Release title (default: tag name) | | `--draft` | No | Create as draft release | | `--prerelease` | No | Mark as prerelease | diff --git a/packages/cli/src/commands/changelog.ts b/packages/cli/src/commands/changelog.ts index 5bdb236..9547c70 100644 --- a/packages/cli/src/commands/changelog.ts +++ b/packages/cli/src/commands/changelog.ts @@ -10,6 +10,7 @@ import { loadConfig, renderKeepAChangelogBody, resolveRange, + updateUnreleased, type ResolvedRange, } from "@polyscribe/core"; import { gatherSources } from "../lib/gather-sources.js"; @@ -26,6 +27,7 @@ export interface ChangelogCommandOptions { dryRun?: boolean; date?: string; notes?: string; + unreleased?: boolean; } export function registerChangelogCommand(program: Command): void { @@ -42,6 +44,10 @@ export function registerChangelogCommand(program: Command): void { "--notes <file>", "use existing markdown file as changelog body (skips LLM)", ) + .option( + "--unreleased", + "update only the [Unreleased] section (no --version required)", + ) .action(async (options: ChangelogCommandOptions) => { const cwd = process.cwd(); @@ -110,6 +116,35 @@ export function registerChangelogCommand(program: Command): void { const changelogPath = resolve(cwd, config.changelogPath); const needsFileUpdate = options.write || options.dryRun; + if (options.unreleased) { + const existing = existsSync(changelogPath) + ? readFileSync(changelogPath, "utf8") + : createEmptyChangelog(); + const updated = updateUnreleased(existing, body); + + if (options.dryRun) { + console.log(renderLineDiff(existing, updated)); + return; + } + + if (options.write) { + writeFileSync(changelogPath, updated, "utf8"); + console.log( + pc.green(`Updated [Unreleased] in ${config.changelogPath}`), + ); + return; + } + + console.log(""); + console.log(body); + console.log(""); + console.log(pc.dim(`Range: ${range.fromRef}..${range.toRef}`)); + console.log( + pc.dim("Tip: use --write --unreleased to update CHANGELOG.md"), + ); + return; + } + if (!needsFileUpdate) { console.log(""); console.log(body); diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index dbea47e..95b1d00 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -25,7 +25,10 @@ export function registerPublishCommand(program: Command): void { .command("publish") .description("Create or update a GitHub Release for an existing tag") .requiredOption("--version <tag>", "tag name, e.g. v1.0.0") - .requiredOption("--notes <file>", "release notes markdown file") + .option( + "--notes <file>", + "release notes markdown file (default: RELEASE.md)", + ) .option("--title <title>", "release title (default: version)") .option("--draft", "create as draft release") .option("--prerelease", "mark as prerelease") @@ -52,9 +55,14 @@ export function registerPublishCommand(program: Command): void { process.exit(EXIT_CONFIG); } - const notesPath = resolve(cwd, options.notes!); + const notesFile = options.notes ?? "RELEASE.md"; + const notesPath = resolve(cwd, notesFile); if (!existsSync(notesPath)) { - console.error(pc.red(`Release notes file not found: ${options.notes}`)); + console.error( + pc.red( + `Release notes file not found: ${notesFile}. Run: polyscribe draft --output RELEASE.md`, + ), + ); process.exit(EXIT_CONFIG); } diff --git a/packages/core/src/changelog/index.ts b/packages/core/src/changelog/index.ts index 304d51c..adba79c 100644 --- a/packages/core/src/changelog/index.ts +++ b/packages/core/src/changelog/index.ts @@ -4,7 +4,7 @@ export { type ParsedChangelog, } from "./parse.js"; -export { insertVersion } from "./merge.js"; +export { insertVersion, updateUnreleased } from "./merge.js"; export function createEmptyChangelog(): string { return `# Changelog diff --git a/packages/core/src/changelog/merge.ts b/packages/core/src/changelog/merge.ts index 0e24fe3..a59ff9f 100644 --- a/packages/core/src/changelog/merge.ts +++ b/packages/core/src/changelog/merge.ts @@ -36,3 +36,23 @@ export function insertVersion( return `${blocks.join("\n\n").replace(/\n{3,}/g, "\n\n")}\n`; } + +/** Replace or insert the ## [Unreleased] section body without adding a version. */ +export function updateUnreleased(changelog: string, content: string): string { + const parsed = parseChangelog(changelog); + const blocks: string[] = []; + + if (parsed.preamble.length > 0) { + blocks.push(parsed.preamble); + } + + blocks.push(`## [Unreleased]${normalizeContent(content)}`); + + for (const existing of parsed.versions) { + blocks.push( + `${formatVersionHeading(existing.version, existing.date)}${normalizeContent(existing.content)}`, + ); + } + + return `${blocks.join("\n\n").replace(/\n{3,}/g, "\n\n")}\n`; +} diff --git a/packages/core/src/draft/generate.ts b/packages/core/src/draft/generate.ts index bd07dbc..ef37a88 100644 --- a/packages/core/src/draft/generate.ts +++ b/packages/core/src/draft/generate.ts @@ -7,7 +7,7 @@ import type { SourceItem, } from "../types.js"; import { suggestSemverFromSources } from "../semver/heuristics.js"; -import { buildDraftPrompts } from "./prompt.js"; +import { buildDraftPrompts, buildCitationRepairPrompt } from "./prompt.js"; import { llmDraftOutputSchema } from "./schema.js"; import { renderDraftMarkdown } from "./render.js"; import type { LLMClient } from "./llm/client.js"; @@ -114,19 +114,38 @@ export async function generateDraft( maxTokens: 4096, }); - const sections: DraftSection[] = output.sections.map((section) => ({ + let sections: DraftSection[] = output.sections.map((section) => ({ type: section.type, title: section.title, content: section.content, sourceIds: section.sourceIds ?? [], })); - const invalid = validateSectionCitations(sections, sourceIdSet); + let invalid = validateSectionCitations(sections, sourceIdSet); + if (invalid.length > 0) { - throw new CitationValidationError( - `Draft failed citation validation (${invalid.length} issue(s))`, - invalid, - ); + const repairUser = `${prompts.user}\n\n${buildCitationRepairPrompt(invalid)}`; + const repaired = await llmClient.completeStructured({ + system: prompts.system, + user: repairUser, + schema: llmDraftOutputSchema, + maxTokens: 4096, + }); + + sections = repaired.sections.map((section) => ({ + type: section.type, + title: section.title, + content: section.content, + sourceIds: section.sourceIds ?? [], + })); + + invalid = validateSectionCitations(sections, sourceIdSet); + if (invalid.length > 0) { + throw new CitationValidationError( + `Draft failed citation validation after repair (${invalid.length} issue(s))`, + invalid, + ); + } } const now = new Date().toISOString(); diff --git a/packages/core/src/draft/prompt.ts b/packages/core/src/draft/prompt.ts index 7ab5ad6..9521324 100644 --- a/packages/core/src/draft/prompt.ts +++ b/packages/core/src/draft/prompt.ts @@ -104,3 +104,19 @@ export function buildDraftPrompts( user: buildDraftUserPrompt(sources, tone, heuristicSemver), }; } + +export function buildCitationRepairPrompt( + invalid: Array<{ type: string; reason: string }>, +): string { + const issues = invalid + .map((item) => `- ${item.type}: ${item.reason}`) + .join("\n"); + + return `Your previous draft failed citation validation. Fix every issue below. +Each non-empty section bullet must include a valid sourceId from the original source list. + +Validation issues: +${issues} + +Return corrected JSON with valid sourceIds on every section.`; +} diff --git a/packages/core/tests/changelog.test.ts b/packages/core/tests/changelog.test.ts index d0b949b..36f49f9 100644 --- a/packages/core/tests/changelog.test.ts +++ b/packages/core/tests/changelog.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { insertVersion, parseChangelog } from "../src/changelog/index.js"; +import { insertVersion, parseChangelog, updateUnreleased } from "../src/changelog/index.js"; const KEEP_A_CHANGELOG = `# Changelog @@ -56,3 +56,18 @@ describe("insertVersion", () => { ); }); }); + +describe("updateUnreleased", () => { + it("replaces Unreleased body while keeping version history", () => { + const updated = updateUnreleased( + KEEP_A_CHANGELOG, + `### Added +- New unreleased feature`, + ); + + expect(updated).toContain("## [Unreleased]"); + expect(updated).toContain("New unreleased feature"); + expect(updated).not.toContain("Draft changelog generation"); + expect(updated).toContain("## [1.0.0] - 2024-01-15"); + }); +}); diff --git a/packages/core/tests/generate-retry.test.ts b/packages/core/tests/generate-retry.test.ts new file mode 100644 index 0000000..d79d903 --- /dev/null +++ b/packages/core/tests/generate-retry.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import type { SourceItem } from "../src/types.js"; +import { generateDraft } from "../src/draft/generate.js"; +import type { LLMClient } from "../src/draft/llm/client.js"; +import type { z } from "zod"; +import { llmDraftOutputSchema } from "../src/draft/schema.js"; + +const source: SourceItem = { + id: "pr:1", + type: "pr", + prNumber: 1, + title: "Add widgets", + author: { login: "alice", id: "1" }, + labels: ["feat"], + url: "https://github.com/acme/repo/pull/1", +}; + +function mockClient( + responses: Array<z.infer<typeof llmDraftOutputSchema>>, +): LLMClient { + let call = 0; + return { + provider: "mock", + model: "mock", + async completeStructured<T>({ + schema, + }: { + schema: { parse: (data: unknown) => T }; + }): Promise<T> { + const data = responses[call] ?? responses[responses.length - 1]!; + call += 1; + return schema.parse(data); + }, + }; +} + +describe("generateDraft citation retry", () => { + it("retries once when first response lacks citations", async () => { + const invalid = { + sections: [ + { + type: "features" as const, + title: "Features", + content: "- Add widgets", + sourceIds: [], + }, + { + type: "credits" as const, + title: "Contributors", + content: "Thanks @alice", + sourceIds: [], + }, + ], + contributors: [{ login: "alice" }], + suggestedSemver: "minor" as const, + }; + + const valid = { + ...invalid, + sections: [ + { + type: "features" as const, + title: "Features", + content: "- Add widgets (#1)", + sourceIds: ["pr:1"], + }, + { + type: "credits" as const, + title: "Contributors", + content: "Thanks @alice", + sourceIds: [], + }, + ], + }; + + const client = mockClient([invalid, valid]); + const draft = await generateDraft([source], { tone: "developer-friendly" }, client); + + expect(draft.sections[0]?.sourceIds).toContain("pr:1"); + }); +});