diff --git a/AGENTS.md b/AGENTS.md index 8bb4e66..5505b7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,907 +2,130 @@ ## Project Overview -**@ucdjs/release-scripts** is a sophisticated monorepo release automation library built on Effect-TS. It provides programmatic tools for managing releases in pnpm workspaces with automated version calculation, dependency graph resolution, and GitHub integration. +**@ucdjs/release-scripts** is a monorepo release automation library for pnpm workspaces. It provides programmatic tools for automated version calculation, dependency graph resolution, changelog generation, and GitHub integration. ### Key Features -- **Automated Version Calculation**: Analyzes git commits using conventional commit standards to determine appropriate version bumps (major, minor, patch) -- **Workspace Management**: Discovers and manages multiple packages in pnpm workspaces -- **Dependency Graph Resolution**: Computes topological ordering for package releases based on workspace dependencies -- **GitHub Integration**: Creates and manages release pull requests, sets commit statuses -- **Release Verification**: Validates that release branches match expected release artifacts -- **Dry-Run Support**: Allows testing release workflows without making actual changes +- **Automated Version Calculation**: Conventional commit analysis to determine version bumps (major, minor, patch) +- **Workspace Management**: Discovers and manages packages in pnpm workspaces +- **Dependency Graph Resolution**: Topological ordering for package releases based on workspace dependencies +- **GitHub Integration**: Creates/manages release PRs, sets commit statuses +- **Release Verification**: Validates release branches match expected artifacts +- **Dry-Run Support**: Test workflows without making changes -### Current Status +### Implemented Workflows -Version: `v0.1.0-beta.24` - -Implemented workflows: -- ✅ `verify()` - Release branch verification -- ✅ `prepare()` - Release preparation with version updates -- ⏳ `publish()` - NPM publishing (planned) +- `verify()` - Release branch verification +- `prepare()` - Release preparation with version updates, PR creation, changelog generation +- `publish()` - NPM publishing with provenance and tag creation ## Architecture -### Effect-TS Functional Architecture - -The codebase uses **Effect-TS** as its foundational architecture, providing: +### Plain TypeScript with Result Pattern -#### Service Pattern -All services extend `Effect.Service` with dependency injection: +The codebase uses plain TypeScript with a `Result` pattern for error handling: ```typescript -export class ServiceName extends Effect.Service()( - "service-identifier", - { effect: Effect.gen(function* () { console.log("Hello from ServiceName"); }), dependencies: [...yourDependencies] } -) {} +type Result = Ok | Err; ``` -#### Key Architectural Benefits - -1. **Dependency Injection**: Effect Layers compose services with automatic dependency resolution -2. **Error Handling**: Tagged errors using `Data.TaggedError` for type-safe error handling -3. **Generator-based Control Flow**: Clean async operations without callback hell -4. **Composability**: Services can be combined and tested in isolation - -#### Error Handling Strategy - -Custom error types in `src/errors.ts`: -- `GitError`: Git command failures -- `PackageNotFoundError`: Missing workspace packages -- `WorkspaceError`: Workspace discovery/validation failures -- `GitHubError`: GitHub API failures -- `CircularDependencyError`: Circular dependencies in dependency graph - -## Service Architecture - -All services are located in `src/services/` and follow the Effect-TS service pattern. - -### GitService -**File**: `src/services/git.service.ts` - -**Purpose**: Wrapper for git command execution - -**Key Operations**: -- Branch management (`create`, `checkout`, `list`, `exists`) -- Commit operations (`stage`, `write`, `push`) -- Tag retrieval (`getMostRecentPackageTag`) -- File reading from specific refs (`readFile`) -- Commit history fetching (via `commit-parser`) -- Working directory validation - -**Dry-Run Support**: All state-changing operations check `config.dryRun` and skip execution - -**Dependencies**: -- `@effect/platform` CommandExecutor -- `commit-parser` for parsing conventional commits - -### GitHubService -**File**: `src/services/github.service.ts` - -**Purpose**: GitHub API client with authentication +Functions that can fail return `Result` values instead of throwing. Callers check `result.ok` to determine success/failure. -**Key Operations**: -- Pull request operations (`getPullRequestByBranch`, `createPullRequest`, `updatePullRequest`) -- Commit status management (`setCommitStatus`) -- Schema validation using Effect Schema +### ReleaseError and Error Boundary -**API Response Schemas**: -- `PullRequest`: Validates PR structure (number, title, body, state, head SHA) -- `CommitStatus`: Validates commit status responses +`exitWithError()` throws a `ReleaseError` instead of calling `process.exit(1)`. This makes error paths testable. -**Supported PR States**: open, closed, merged +At the entry point (`src/index.ts`), a `withErrorBoundary` wrapper catches `ReleaseError`, prints formatted output via `printReleaseError`, and calls `process.exit(1)`. -**Authentication**: Uses GitHub token from configuration - -### WorkspaceService -**File**: `src/services/workspace.service.ts` - -**Purpose**: Package discovery and management in pnpm workspaces - -**Key Operations**: -- Discovers packages using `pnpm -r ls --json` -- Reads/writes package.json files -- Filters packages by include/exclude lists -- Identifies workspace dependencies vs external dependencies -- Supports private package exclusion - -**Caching**: Caches workspace package list for performance - -**Package Structure**: ```typescript -interface WorkspacePackage { - name: string; - version: string; - path: string; - private?: boolean; - dependencies?: Record; - devDependencies?: Record; - peerDependencies?: Record; -} -``` - -### VersionCalculatorService -**File**: `src/services/version-calculator.service.ts` - -**Purpose**: Determines version bumps from conventional commits - -**Bump Rules**: -- Breaking changes (`BREAKING CHANGE:`, `!`) → **major** -- Features (`feat:`) → **minor** -- Fixes/Performance (`fix:`, `perf:`) → **patch** -- Other commits → **none** - -**Bump Priority**: none < patch < minor < major - -**Features**: -- Supports version overrides from configuration -- Distinguishes between direct changes vs dependency updates -- Calculates bump type for dependency-only updates - -### DependencyGraphService -**File**: `src/services/dependency-graph.service.ts` - -**Purpose**: Builds and analyzes package dependency graphs - -**Key Operations**: -- Builds dependency graph from workspace packages -- Computes topological ordering using Kahn's algorithm -- Detects circular dependencies with detailed error messages -- Assigns "level" to each package based on dependency depth - -**Level Assignment**: -- Level 0: No workspace dependencies -- Level N: Depends on packages at level N-1 - -**Error Handling**: Throws `CircularDependencyError` with full cycle path - -### PackageUpdaterService -**File**: `src/services/package-updater.service.ts` - -**Purpose**: Applies version bumps to package.json files - -**Key Operations**: -- Applies version bumps to package.json files -- Updates dependency ranges (dependencies, devDependencies, peerDependencies) -- Handles `workspace:` protocol ranges -- Preserves semver range prefixes (`^`, `~`) -- Validates that new versions satisfy existing ranges - -**Complex Range Handling**: -- Validates complex ranges before updating -- Throws error if update would break existing range constraints -- Supports workspace: protocol with version suffixes - -### NPMService -**File**: `src/services/npm.service.ts` - -**Purpose**: NPM registry client - -**Key Operations**: -- Fetches package metadata (packument) from NPM registry -- Checks if specific versions exist on NPM -- Retrieves latest published version -- 404 handling for unpublished packages - -**Registry**: Defaults to `https://registry.npmjs.org` - -## Helper Utilities - -**File**: `src/utils/helpers.ts` - -### loadOverrides() -Reads version override configuration from `.github/ucdjs-release.overrides.json` - -**Override Structure**: -```json -{ - "overrides": { - "@package/name": { - "version": "1.2.3", - "dependencies": { - "dependency-name": "^2.0.0" - } - } - } -} -``` - -### mergePackageCommitsIntoPackages() -Enriches packages with commits since last tag - -**Process**: -1. Gets most recent tag for each package -2. Fetches commits since that tag -3. Filters commits by package path -4. Merges commits into package metadata - -### mergeCommitsAffectingGloballyIntoPackage() -Sophisticated global commit attribution - -**Modes**: -- `"none"`: No global commits attributed -- `"all"`: All global commits attributed to all packages -- `"dependencies"`: Only dependency-related global commits attributed - -**Smart Attribution**: -- Prevents double-counting commits across package releases -- Timestamp-based filtering to attribute global commits correctly -- Handles edge case: pkg-a released → global change → pkg-b released - -**Global Commit Detection**: -- Commits affecting files outside package directories -- Root-level dependency files (package.json, pnpm-lock.yaml) -- Workspace-wide configuration files - -### isGlobalCommit() -Identifies commits affecting files outside package directories - -### isDependencyFile() -Detects dependency-related files: -- `package.json` -- `pnpm-lock.yaml` -- `pnpm-workspace.yaml` - -### findCommitRange() -Finds oldest/newest commits across all packages for changelog generation - -## Main Entry Point - -**File**: `src/index.ts` - -### API Interface - -```typescript -export interface ReleaseScripts { - verify: () => Promise; // Verify release branch integrity - prepare: () => Promise; // Prepare release (calculate & update versions) - publish: () => Promise; // Publish to NPM (not yet implemented) - packages: { - list: () => Promise; - get: (packageName: string) => Promise; - }; -} - -export async function createReleaseScripts( - options: ReleaseScriptsOptionsInput -): Promise; -``` - -### Initialization Flow - -1. **Normalize Options**: Validates and normalizes configuration -2. **Construct Effect Layer**: Builds dependency injection layer with all services -3. **Validate Workspace**: Ensures valid git repository and clean working directory -4. **Return API**: Provides typed API for release operations - -### Configuration Options - -```typescript -interface ReleaseScriptsOptionsInput { - repo: string; // "owner/repo" - githubToken?: string; // GitHub API token - workspaceRoot?: string; // Path to workspace root - packages?: { - include?: string[]; // Package name filters - exclude?: string[]; // Package name exclusions - excludePrivate?: boolean; // Exclude private packages - }; - branch?: { - release?: string; // Release branch name - default?: string; // Default/main branch - }; - globalCommitMode?: "none" | "all" | "dependencies"; - dryRun?: boolean; // Enable dry-run mode -} -``` - -## Core Workflows - -### verify() - Release Verification - -**Purpose**: Ensures release branch matches expected release artifacts - -**Process**: -1. Fetches release PR by branch name -2. Loads version overrides from `.github/ucdjs-release.overrides.json` -3. Discovers workspace packages (filtered by config) -4. Merges package-specific and global commits -5. Calculates expected version bumps for all packages -6. Reads package.json files from release branch HEAD -7. Compares expected vs actual versions/dependencies -8. Reports drift (version mismatches, dependency range issues) -9. Sets GitHub commit status (success/failure) - -**Exit Codes**: -- 0: All packages match expected state -- 1: Drift detected or errors occurred - -**Implementation**: `src/verify.ts` - -### prepare() - Release Preparation - -**Purpose**: Prepares release by calculating and applying version updates - -**Process**: -1. Fetches release PR (or prepares to create one) -2. Loads version overrides -3. Discovers packages and enriches with commits -4. Calculates version bumps for all packages -5. Computes topological order (dependency-aware) -6. Applies releases (updates package.json files) -7. Creates/updates PR (future enhancement) -8. Generates changelogs (future enhancement) - -**Current State**: Updates package.json files locally - -**Future Enhancements**: -- PR creation/update automation -- Changelog generation -- Release notes formatting - -### publish() - NPM Publishing - -**Status**: Not yet implemented - -**Planned Features**: -- Publish packages to NPM in topological order -- Respect private package flags -- Provenance generation -- Tag creation after successful publish - -## Workflow Diagrams - -> **Note**: All diagrams are also available as separate Mermaid files in [`docs/diagrams/`](../docs/diagrams/) for reuse and image generation: -> - [verify-workflow.mmd](../docs/diagrams/verify-workflow.mmd) - Sequence diagram -> - [prepare-workflow.mmd](../docs/diagrams/prepare-workflow.mmd) - Sequence diagram -> - [publish-workflow.mmd](../docs/diagrams/publish-workflow.mmd) - Sequence diagram -> - [service-dependency-graph.mmd](../docs/diagrams/service-dependency-graph.mmd) - Class diagram -> - [commit-attribution-flow.mmd](../docs/diagrams/commit-attribution-flow.mmd) - Sequence diagram -> - [version-bump-calculation.mmd](../docs/diagrams/version-bump-calculation.mmd) - Flowchart -> - [package-lifecycle.mmd](../docs/diagrams/package-lifecycle.mmd) - State diagram - -### verify() Workflow - -**Diagram Type**: Sequence diagram showing service interactions -**File**: [`docs/diagrams/verify-workflow.mmd`](../docs/diagrams/verify-workflow.mmd) - -```mermaid -flowchart TD - Start([Start verify]) --> FetchPR[Fetch Release PR by branch] - FetchPR --> CheckPR{PR exists?} - CheckPR -->|No| ErrorNoPR[Error: No release PR found] - CheckPR -->|Yes| LoadOverrides[Load version overrides
.github/ucdjs-release.overrides.json] - - LoadOverrides --> DiscoverPkgs[Discover workspace packages
pnpm -r ls --json] - DiscoverPkgs --> FilterPkgs[Filter packages
include/exclude/private] - - FilterPkgs --> GetTags[Get most recent tag
for each package] - GetTags --> FetchCommits[Fetch commits since tag
for each package] - - FetchCommits --> MergeCommits[Merge package-specific commits] - MergeCommits --> GlobalCommits{Global commit
mode?} - - GlobalCommits -->|none| CalcVersions[Calculate expected versions
from commits] - GlobalCommits -->|all| MergeGlobalAll[Merge all global commits
into all packages] - GlobalCommits -->|dependencies| MergeGlobalDeps[Merge dependency-related
global commits] - - MergeGlobalAll --> CalcVersions - MergeGlobalDeps --> CalcVersions - - CalcVersions --> ApplyOverrides[Apply version overrides
from config] - ApplyOverrides --> BuildDepGraph[Build dependency graph] - BuildDepGraph --> CheckCircular{Circular
dependencies?} - - CheckCircular -->|Yes| ErrorCircular[Error: Circular dependency] - CheckCircular -->|No| TopoSort[Compute topological order] - - TopoSort --> ReadActual[Read package.json files
from release branch HEAD] - ReadActual --> Compare[Compare expected vs actual
versions & dependencies] - - Compare --> CheckDrift{Drift detected?} - CheckDrift -->|Yes| ReportDrift[Report drift details
versions/dependencies] - CheckDrift -->|No| ReportSuccess[Report: All packages match] - - ReportDrift --> SetStatusFail[Set GitHub commit status
state: failure] - ReportSuccess --> SetStatusSuccess[Set GitHub commit status
state: success] - - SetStatusFail --> Exit1([Exit code 1]) - SetStatusSuccess --> Exit0([Exit code 0]) - - ErrorNoPR --> Exit1 - ErrorCircular --> Exit1 - - style Start fill:#e1f5e1 - style Exit0 fill:#e1f5e1 - style Exit1 fill:#ffe1e1 - style ErrorNoPR fill:#ffcccc - style ErrorCircular fill:#ffcccc - style CheckDrift fill:#fff4e1 - style SetStatusSuccess fill:#d4edda - style SetStatusFail fill:#f8d7da -``` - -### prepare() Workflow - -**Diagram Type**: Sequence diagram showing service interactions -**File**: [`docs/diagrams/prepare-workflow.mmd`](../docs/diagrams/prepare-workflow.mmd) - -```mermaid -flowchart TD - Start([Start prepare]) --> FetchPR[Fetch/Prepare Release PR] - FetchPR --> LoadOverrides[Load version overrides
.github/ucdjs-release.overrides.json] - - LoadOverrides --> DiscoverPkgs[Discover workspace packages
pnpm -r ls --json] - DiscoverPkgs --> FilterPkgs[Filter packages
include/exclude/private] - - FilterPkgs --> GetTags[Get most recent tag
for each package] - GetTags --> FetchCommits[Fetch commits since tag
for each package] - - FetchCommits --> MergeCommits[Merge package-specific commits] - MergeCommits --> GlobalCommits{Global commit
mode?} - - GlobalCommits -->|none| CalcVersions[Calculate version bumps
from commits] - GlobalCommits -->|all| MergeGlobalAll[Merge all global commits
into all packages] - GlobalCommits -->|dependencies| MergeGlobalDeps[Merge dependency-related
global commits] +// In workflow code: +exitWithError("message", "hint", cause); // throws ReleaseError - MergeGlobalAll --> CalcVersions - MergeGlobalDeps --> CalcVersions - - CalcVersions --> ApplyOverrides[Apply version overrides
from config] - ApplyOverrides --> BuildDepGraph[Build dependency graph] - BuildDepGraph --> CheckCircular{Circular
dependencies?} - - CheckCircular -->|Yes| ErrorCircular[Error: Circular dependency] - CheckCircular -->|No| TopoSort[Compute topological order
with levels] - - TopoSort --> LoopPkgs{More packages
to update?} - LoopPkgs -->|Yes| NextPkg[Get next package
in topo order] - - NextPkg --> ApplyVersion[Update package version
in package.json] - ApplyVersion --> UpdateDeps[Update workspace dependencies
preserve ranges & workspace: protocol] - - UpdateDeps --> DryRun{Dry-run
mode?} - DryRun -->|Yes| LogUpdate[Log: Would update package.json] - DryRun -->|No| WriteFile[Write package.json to disk] - - LogUpdate --> LoopPkgs - WriteFile --> LoopPkgs - - LoopPkgs -->|No| Future[Future: Create/Update PR
Generate changelogs] - Future --> Success([Success]) - - ErrorCircular --> Exit1([Exit code 1]) - - style Start fill:#e1f5e1 - style Success fill:#e1f5e1 - style Exit1 fill:#ffe1e1 - style ErrorCircular fill:#ffcccc - style DryRun fill:#fff4e1 - style Future fill:#e1f0ff,stroke:#4a90e2,stroke-dasharray: 5 5 +// At entry boundary (src/index.ts): +withErrorBoundary(() => verify(options)); // catches ReleaseError → print → exit ``` -### publish() Workflow (Planned) - -**Diagram Type**: Sequence diagram showing service interactions -**File**: [`docs/diagrams/publish-workflow.mmd`](../docs/diagrams/publish-workflow.mmd) - -```mermaid -flowchart TD - Start([Start publish]) --> DiscoverPkgs[Discover workspace packages] - DiscoverPkgs --> FilterPrivate[Filter out private packages] - - FilterPrivate --> BuildDepGraph[Build dependency graph] - BuildDepGraph --> TopoSort[Compute topological order
with levels] - - TopoSort --> GroupByLevel[Group packages by level
for parallel publishing] - - GroupByLevel --> LoopLevels{More levels
to publish?} - LoopLevels -->|Yes| NextLevel[Get next level] +### CLI Flags - NextLevel --> ParallelPub[Publish packages in parallel
within level] +CLI flags (`--dry`, `--verbose`, `--force`) are parsed lazily via `node:util parseArgs` through getter functions (`getIsDryRun()`, `getIsVerbose()`, `getIsForce()`, `getIsCI()`). This avoids module-level side effects and makes the code testable. - ParallelPub --> LoopPkgsInLevel{More packages
in level?} - LoopPkgsInLevel -->|Yes| NextPkg[Get next package] +## Module Structure - NextPkg --> CheckNPM[Check if version exists on NPM
NPMService.getPackument] - CheckNPM --> Exists{Version
exists?} - - Exists -->|Yes| SkipPkg[Skip: Already published] - Exists -->|No| BuildPkg[Build package
pnpm build --filter] - - BuildPkg --> PublishPkg[Publish to NPM
pnpm publish --provenance] - PublishPkg --> CreateTag[Create git tag
@package/name@version] - CreateTag --> PushTag[Push tag to remote] - - PushTag --> LoopPkgsInLevel - SkipPkg --> LoopPkgsInLevel - - LoopPkgsInLevel -->|No| WaitLevel[Wait for all packages
in level to complete] - WaitLevel --> LoopLevels - - LoopLevels -->|No| UpdatePR[Update Release PR
with publish results] - UpdatePR --> Success([Success]) - - style Start fill:#e1f5e1 - style Success fill:#e1f5e1 - style ParallelPub fill:#fff4e1 - style PublishPkg fill:#d4edda - style SkipPkg fill:#f0f0f0 - style Start stroke:#4a90e2,stroke-width:3px,stroke-dasharray: 5 5 - style Success stroke:#4a90e2,stroke-width:3px,stroke-dasharray: 5 5 ``` - -### Service Dependency Graph - -**Diagram Type**: Class diagram showing structure and relationships -**File**: [`docs/diagrams/service-dependency-graph.mmd`](../docs/diagrams/service-dependency-graph.mmd) - -```mermaid -graph TD - Config[Configuration
ReleaseScriptsOptions] - - Git[GitService
git commands] - GitHub[GitHubService
GitHub API] - Workspace[WorkspaceService
package discovery] - VersionCalc[VersionCalculatorService
version bumps] - DepGraph[DependencyGraphService
topological sort] - PkgUpdater[PackageUpdaterService
package.json updates] - NPM[NPMService
NPM registry] - - Helpers[Helper Utilities
commit attribution] - - Config --> Git - Config --> GitHub - Config --> Workspace - - Git --> Workspace - Workspace --> VersionCalc - Workspace --> DepGraph - - Git --> Helpers - Workspace --> Helpers - - Helpers --> VersionCalc - VersionCalc --> PkgUpdater - DepGraph --> PkgUpdater - Workspace --> PkgUpdater - - Workspace --> NPM - - GitHub -.-> |used by verify|Verify[verify.ts] - Git -.-> |used by verify|Verify - Workspace -.-> |used by verify|Verify - VersionCalc -.-> |used by verify|Verify - DepGraph -.-> |used by verify|Verify - Helpers -.-> |used by verify|Verify - - Git -.-> |used by prepare|Prepare[prepare flow] - Workspace -.-> |used by prepare|Prepare - VersionCalc -.-> |used by prepare|Prepare - DepGraph -.-> |used by prepare|Prepare - PkgUpdater -.-> |used by prepare|Prepare - Helpers -.-> |used by prepare|Prepare - - NPM -.-> |used by publish|Publish[publish flow
planned] - Git -.-> |used by publish|Publish - Workspace -.-> |used by publish|Publish - DepGraph -.-> |used by publish|Publish - - style Config fill:#e1f0ff - style Git fill:#ffe1e1 - style GitHub fill:#ffe1e1 - style Workspace fill:#e1ffe1 - style VersionCalc fill:#fff4e1 - style DepGraph fill:#fff4e1 - style PkgUpdater fill:#f0e1ff - style NPM fill:#ffe1e1 - style Helpers fill:#f0f0f0 - style Verify fill:#d4edda - style Prepare fill:#d4edda - style Publish fill:#e1f0ff,stroke:#4a90e2,stroke-dasharray: 5 5 -``` - -### Commit Attribution Flow - -**Diagram Type**: Sequence diagram showing the attribution algorithm -**File**: [`docs/diagrams/commit-attribution-flow.mmd`](../docs/diagrams/commit-attribution-flow.mmd) - -```mermaid -flowchart TD - Start([Packages with commits]) --> HasGlobal{Global commit
mode?} - - HasGlobal -->|none| NoGlobal[No global commits added] - HasGlobal -->|all| GetAllGlobal[Get all global commits
from main branch] - HasGlobal -->|dependencies| GetDepGlobal[Get dependency-related
global commits] - - GetAllGlobal --> FindRange[Find commit time range
across all packages] - GetDepGlobal --> FindRange - - FindRange --> FilterTime[Filter global commits
by timestamp range] - FilterTime --> LoopPkgs{More packages?} - - LoopPkgs -->|Yes| NextPkg[Get next package] - NextPkg --> GetLastRelease[Get last release timestamp
from most recent tag] - - GetLastRelease --> FilterCommits[Filter global commits:
commit.time > lastRelease.time] - FilterCommits --> MergeInto[Merge filtered commits
into package] - - MergeInto --> LoopPkgs - LoopPkgs -->|No| Result([Packages with attributed commits]) - NoGlobal --> Result - - style Start fill:#e1f5e1 - style Result fill:#e1f5e1 - style HasGlobal fill:#fff4e1 - style FilterCommits fill:#d4edda +src/ +├── index.ts # Entry point, API surface, error boundary +├── options.ts # Configuration normalization +├── types.ts # Result, ReleaseResult +├── core/ +│ ├── git.ts # Git operations (branch, commit, tag, push) +│ ├── github.ts # GitHub API client +│ ├── workspace.ts # Package discovery via pnpm +│ ├── changelog.ts # Changelog generation +│ └── prompts.ts # Interactive prompts +├── operations/ +│ ├── semver.ts # Semver utilities (getNextVersion, calculateBumpType) +│ ├── version.ts # Bump determination from commits +│ ├── branch.ts # Release branch operations +│ ├── calculate.ts # Update calculation orchestration +│ └── pr.ts # Pull request sync +├── shared/ +│ ├── errors.ts # ReleaseError, exitWithError, formatUnknownError, printReleaseError +│ ├── utils.ts # CLI flags, logger, run/dryRun/runIfNotDry +│ └── types.ts # PackageRelease, BumpKind, etc. +├── versioning/ +│ ├── version.ts # Version calculation, dependency range computation +│ ├── commits.ts # Commit grouping, global commit filtering +│ └── package.ts # Dependency graph, dependent updates +└── workflows/ + ├── prepare.ts # Prepare workflow + ├── publish.ts # Publish workflow + └── verify.ts # Verify workflow ``` -### Version Bump Calculation - -**Diagram Type**: Flowchart showing decision logic -**File**: [`docs/diagrams/version-bump-calculation.mmd`](../docs/diagrams/version-bump-calculation.mmd) - -```mermaid -flowchart TD - Start([Package with commits]) --> HasOverride{Has version
override?} - - HasOverride -->|Yes| UseOverride[Use overridden version] - HasOverride -->|No| CheckCommits{Has commits?} - - CheckCommits -->|No| NoBump[Bump type: none] - CheckCommits -->|Yes| AnalyzeCommits[Analyze each commit] - - AnalyzeCommits --> LoopCommits{More commits?} - LoopCommits -->|Yes| NextCommit[Get next commit] - - NextCommit --> CheckBreaking{Breaking change?
BREAKING CHANGE:
or !} - CheckBreaking -->|Yes| Major[Bump type: major] - CheckBreaking -->|No| CheckFeat{Feature?
feat:} - - CheckFeat -->|Yes| Minor[Bump type: minor] - CheckFeat -->|No| CheckFix{Fix or perf?
fix: or perf:} +### Key Exported Pure Functions (testable) - CheckFix -->|Yes| Patch[Bump type: patch] - CheckFix -->|No| Other[No bump for this commit] +- `resolveAutoVersion()` - Determines version bump from commits + overrides (no IO) +- `computeDependencyRange()` - Computes new dependency range string (no IO) +- `getDependencyUpdates()` - Finds which deps need updating (no IO) +- `filterGlobalCommits()` - Filters commits by global/dependency criteria (no IO) +- `fileMatchesPackageFolder()`, `isGlobalCommit()`, `findCommitRange()` - Commit classification helpers - Major --> UpdateMax[Update max bump type] - Minor --> UpdateMax - Patch --> UpdateMax - Other --> LoopCommits +## Error Types - UpdateMax --> LoopCommits - LoopCommits -->|No| ApplyBump[Apply highest bump type
to current version] - - ApplyBump --> Result([New version]) - UseOverride --> Result - NoBump --> Result - - style Start fill:#e1f5e1 - style Result fill:#e1f5e1 - style Major fill:#ffcccc - style Minor fill:#fff4cc - style Patch fill:#ccffcc - style NoBump fill:#f0f0f0 - style UseOverride fill:#e1f0ff -``` +- `GitError` - Git command failures (`src/core/git.ts`) +- `WorkspaceError` - Workspace discovery/validation (`src/core/workspace.ts`) +- `ReleaseError` - Workflow-level errors, thrown by `exitWithError` (`src/shared/errors.ts`) ## Technology Stack -### Core Dependencies - -**Effect-TS Ecosystem**: -- `effect@3.19.9`: Functional effect system -- `@effect/platform@0.93.6`: Platform abstractions -- `@effect/platform-node@0.103.0`: Node.js implementations -- Effect Schema for runtime validation -- Effect Layer for dependency injection - -**Git & Commit Analysis**: -- `commit-parser@1.3.0`: Conventional commit parsing -- Native git commands via CommandExecutor - -**Package Management**: -- `pnpm` workspace integration -- `semver@7.7.3`: Semantic versioning utilities - -**Utilities**: -- `@luxass/utils@2.7.2`: General utilities -- `farver@1.0.0-beta.1`: Color utilities -- `mri@1.2.0`: CLI argument parsing -- `prompts@2.4.2`: Interactive prompts -- `tinyexec@1.0.2`: Lightweight process execution - -### Development Dependencies - -**Build Tooling**: -- `tsdown@0.17.0`: TypeScript bundler (Rolldown-based) -- `typescript@5.9.3` - -**Testing**: -- `vitest@4.0.15`: Test runner -- `@effect/vitest@0.27.0`: Effect-specific test utilities -- `vitest-testdirs@4.3.0`: Test directory management - -**Linting**: -- `eslint@9.39.1` -- `@luxass/eslint-config@6.0.3`: Custom ESLint configuration - -**Template Engine**: -- `eta@4.4.1`: JavaScript templating (for changelogs/PR bodies) - -## Build & Development +- **Runtime**: Node.js with ESM +- **Build**: tsdown (Rolldown-based bundler) +- **Test**: Vitest +- **Git/Commits**: commit-parser for conventional commit parsing +- **Package Management**: pnpm workspace integration +- **Semver**: semver package +- **CLI**: node:util parseArgs (built-in) +- **Process execution**: tinyexec +- **Colors**: farver +- **Prompts**: prompts +- **Templates**: eta (changelog/PR body) -### Build Process - -**Configuration**: `tsdown.config.ts` - -**Output**: -- `dist/index.mjs`: Bundled ESM -- `dist/index.d.mts`: TypeScript declarations -- Separate chunk for `eta` template engine - -**Features**: -- Tree-shaking enabled -- DTS generation -- ESM-only output -- Advanced chunking - -### Scripts +## Scripts ```bash pnpm build # Build production bundle -pnpm dev # Watch mode for development +pnpm dev # Watch mode pnpm test # Run Vitest tests -pnpm lint # Run ESLint +pnpm lint # ESLint pnpm typecheck # TypeScript type checking ``` -### TypeScript Configuration - -**Target**: ES2022 -**Module**: ESNext with Bundler resolution -**Strict Mode**: Enabled with `noUncheckedIndexedAccess` -**Effect Plugin**: Language service integration enabled - -### Import Aliases - -```txt -#services/* → ./src/services/*.service.ts -``` - -## Testing Strategy - -**Framework**: Vitest with Effect test utilities - -**Coverage Areas**: -- Helper functions (commit classification, range finding) -- Options normalization -- NPM service mocking -- Effect Layer composition - -**Test Utilities**: -- `@effect/vitest`: Effect-aware test helpers -- `vitest-testdirs`: Isolated test workspaces - -## CI/CD Workflows - -### CI Pipeline -**File**: `.github/workflows/ci.yml` - -**Triggers**: Push to main, Pull requests - -**Steps**: -1. Checkout -2. Setup Node.js and pnpm -3. Install dependencies (frozen lockfile) -4. Build -5. Lint -6. Type check - -### Release Pipeline -**File**: `.github/workflows/release.yml` - -**Triggers**: Tag push (`v*`) - -**Steps**: -1. Checkout with full git history -2. Generate changelog (changelogithub) -3. Install dependencies and build -4. Detect tag type (pre-release vs stable) -5. Publish to NPM with provenance -6. NPM OIDC authentication - -## Key Architectural Patterns - -### 1. Command Execution Abstraction -- Git/shell commands via `@effect/platform` CommandExecutor -- Automatic error mapping to custom error types -- Working directory management - -### 2. Schema Validation -- Effect Schema for runtime validation -- GitHub API responses validated against schemas -- Package.json structure validation - -### 3. Dry-Run Mode -- State-changing operations check `config.dryRun` -- Git commands that modify state return success messages instead -- File writes skipped in dry-run mode - -### 4. Commit Attribution Algorithm -- Sophisticated timestamp-based filtering for global commits -- Prevents double-counting across releases -- Three modes: "none", "all", "dependencies" -- Handles edge case: pkg-a released → global change → pkg-b released - -### 5. Dependency Graph Topological Sort -- Kahn's algorithm for cycle detection -- Level assignment for parallel release potential -- Clear error messages for circular dependencies - -### 6. Semver Range Updating -- Preserves `workspace:` protocol -- Maintains range prefixes (`^`, `~`) -- Validates complex ranges -- Throws if new version breaks existing range constraints - -### 7. Monorepo-Aware Design -- Package-scoped git tags (`@package/name@1.0.0`) -- Workspace dependency tracking -- Global vs package-specific commit distinction - -## File Structure - -``` -src/ -├── services/ # Effect-based services -│ ├── git.service.ts # Git operations -│ ├── github.service.ts # GitHub API -│ ├── workspace.service.ts # Package discovery -│ ├── version-calculator.service.ts # Version bump logic -│ ├── dependency-graph.service.ts # Topological sorting -│ ├── package-updater.service.ts # package.json updates -│ └── npm.service.ts # NPM registry client -├── utils/ -│ └── helpers.ts # Global commit handling, overrides -├── index.ts # Main entry point & API -├── options.ts # Configuration normalization -├── errors.ts # Custom error types -└── verify.ts # Release verification program -``` - -## Future Enhancements - -### Short-term -- Complete `publish()` workflow implementation -- PR creation/update automation in `prepare()` -- Changelog generation using `eta` templates - -### Medium-term -- Interactive mode with `prompts` integration -- Custom commit parsers beyond conventional commits -- Configurable tag formats - -### Long-term -- Support for other package managers (npm, yarn) -- Plugin system for custom release workflows -- Release notes generation with AI summarization - -## Contributing - -When working on this codebase: - -1. **Understand Effect-TS**: Familiarize yourself with Effect-TS patterns -2. **Service Pattern**: Follow the established service pattern for new features -3. **Error Handling**: Use tagged errors for type-safe error handling -4. **Dry-Run**: Ensure state-changing operations support dry-run mode -5. **Testing**: Add tests for new functionality using Vitest -6. **Type Safety**: Leverage TypeScript strict mode and `noUncheckedIndexedAccess` +## Testing -## References +Tests use Vitest. Test helpers in `test/_shared.ts` provide factory functions: +- `createCommit()` - Git commit fixture +- `createWorkspacePackage()` - Package fixture +- `createNormalizedReleaseOptions()` - Options fixture +- `createGitHubClientStub()` - GitHub client stub -- [Effect-TS Documentation](https://effect.website/) -- [Conventional Commits](https://www.conventionalcommits.org/) -- [pnpm Workspaces](https://pnpm.io/workspaces) -- [GitHub REST API](https://docs.github.com/en/rest) +Coverage areas: error formatting, CI detection, version resolution, dependency range computation, global commit filtering, commit classification helpers, changelog generation, semver operations. diff --git a/package.json b/package.json index 553f866..9fe3bfd 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ "@luxass/utils": "2.7.3", "commit-parser": "1.3.0", "farver": "1.0.0-beta.1", - "mri": "1.2.0", "prompts": "2.4.2", "semver": "7.7.4", "tinyexec": "1.0.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 121312c..dfa47c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,6 @@ importers: farver: specifier: 1.0.0-beta.1 version: 1.0.0-beta.1 - mri: - specifier: 1.2.0 - version: 1.2.0 prompts: specifier: 2.4.2 version: 2.4.2 @@ -1547,10 +1544,6 @@ packages: mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} - mri@1.2.0: - resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} - engines: {node: '>=4'} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -3618,8 +3611,6 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.1 - mri@1.2.0: {} - ms@2.1.3: {} nanoid@3.3.11: {} diff --git a/src/core/git.ts b/src/core/git.ts index 8dfdcfa..dd626c2 100644 --- a/src/core/git.ts +++ b/src/core/git.ts @@ -277,7 +277,7 @@ export async function checkoutBranch( return ok(true); } - console.warn(`Unexpected git checkout output: ${output}`); + logger.warn(`Unexpected git checkout output: ${output}`); return ok(false); } catch (error) { const gitError = toGitError("checkoutBranch", error); @@ -651,7 +651,7 @@ export async function getGroupedFilesByCommitSha( * @param workspaceRoot - The root directory of the workspace * @returns Result indicating success or failure */ -export async function createPackageTag( +async function createPackageTag( packageName: string, version: string, workspaceRoot: string, @@ -678,7 +678,7 @@ export async function createPackageTag( * @param workspaceRoot - The root directory of the workspace * @returns Result indicating success or failure */ -export async function pushTag( +async function pushTag( tagName: string, workspaceRoot: string, ): Promise> { diff --git a/src/core/github.ts b/src/core/github.ts index 8381a77..ad36b91 100644 --- a/src/core/github.ts +++ b/src/core/github.ts @@ -22,16 +22,16 @@ export interface GitHubPullRequest { }; } -export type CommitStatusState = "error" | "failure" | "pending" | "success"; +type CommitStatusState = "error" | "failure" | "pending" | "success"; -export interface CommitStatusOptions { +interface CommitStatusOptions { state: CommitStatusState; targetUrl?: string; description?: string; context: string; } -export interface UpsertPullRequestOptions { +interface UpsertPullRequestOptions { title: string; body: string; head?: string; @@ -39,14 +39,14 @@ export interface UpsertPullRequestOptions { pullNumber?: number; } -export interface UpsertReleaseOptions { +interface UpsertReleaseOptions { tagName: string; name: string; body?: string; prerelease?: boolean; } -export interface GitHubRelease { +interface GitHubRelease { id: number; tagName: string; name: string; diff --git a/src/core/npm.ts b/src/core/npm.ts index 92a053d..6c49258 100644 --- a/src/core/npm.ts +++ b/src/core/npm.ts @@ -6,7 +6,7 @@ import { logger, runIfNotDry } from "#shared/utils"; import { err, ok } from "#types"; import semver from "semver"; -export interface NPMError { +interface NPMError { type: "npm"; operation: string; message: string; @@ -15,7 +15,7 @@ export interface NPMError { status?: number; } -export interface NPMPackageMetadata { +interface NPMPackageMetadata { "name": string; "dist-tags": Record; "versions": Record; @@ -70,7 +70,7 @@ function getRegistryURL(): string { * @param packageName - The package name (e.g., "lodash" or "@scope/name") * @returns Result with package metadata or error */ -export async function getPackageMetadata( +async function getPackageMetadata( packageName: string, ): Promise> { try { diff --git a/src/core/result-helpers.ts b/src/core/result-helpers.ts deleted file mode 100644 index 93b8f2e..0000000 --- a/src/core/result-helpers.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Result } from "#types"; -import { err, ok } from "#types"; - -export function map(result: Result, mapper: (value: T) => U): Result { - if (!result.ok) { - return result; - } - - return ok(mapper(result.value)); -} - -export async function mapAsync(result: Result, mapper: (value: T) => Promise): Promise> { - if (!result.ok) { - return result; - } - - return ok(await mapper(result.value)); -} - -export function mapErr(result: Result, mapper: (error: E) => F): Result { - if (result.ok) { - return result; - } - - return err(mapper(result.error)); -} - -export function andThen(result: Result, mapper: (value: T) => Result): Result { - if (!result.ok) { - return result; - } - - return mapper(result.value); -} - -export async function andThenAsync(result: Result, mapper: (value: T) => Promise>): Promise> { - if (!result.ok) { - return result; - } - - return mapper(result.value); -} diff --git a/src/core/workspace.ts b/src/core/workspace.ts index adf398e..a05f501 100644 --- a/src/core/workspace.ts +++ b/src/core/workspace.ts @@ -7,8 +7,7 @@ import type { NormalizedReleaseScriptsOptions } from "../options"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { selectPackagePrompt } from "#core/prompts"; -import { exitWithError } from "#shared/errors"; -import { isCI, logger, run } from "#shared/utils"; +import { getIsCI, logger, run } from "#shared/utils"; import { err, ok } from "#types"; import farver from "farver"; @@ -81,10 +80,11 @@ export async function discoverWorkspacePackages( const missing = explicitPackages.filter((p) => !foundNames.has(p)); if (missing.length > 0) { - exitWithError( - `Package${missing.length > 1 ? "s" : ""} not found in workspace: ${missing.join(", ")}`, - "Check your package names or run 'pnpm ls' to see available packages", - ); + return err(toWorkspaceError( + "discoverWorkspacePackages", + `Package${missing.length > 1 ? "s" : ""} not found in workspace: ${missing.join(", ")}. ` + + `Check your package names or run 'pnpm ls' to see available packages`, + )); } } @@ -94,7 +94,7 @@ export async function discoverWorkspacePackages( // 3. No explicit packages were specified (user didn't pre-select specific packages) const isPackagePromptEnabled = options.prompts?.packages !== false; logger.verbose("Package prompt gating", { - isCI, + isCI: getIsCI(), isPackagePromptEnabled, hasExplicitPackages: Boolean(explicitPackages), include: workspaceOptions.include ?? [], @@ -102,7 +102,7 @@ export async function discoverWorkspacePackages( excludePrivate: workspaceOptions.excludePrivate ?? false, }); - if (!isCI && isPackagePromptEnabled && !explicitPackages) { + if (!getIsCI() && isPackagePromptEnabled && !explicitPackages) { const selectedNames = await selectPackagePrompt(workspacePackages); workspacePackages = workspacePackages.filter((pkg) => selectedNames.includes(pkg.name), diff --git a/src/index.ts b/src/index.ts index 2bb47c5..655e802 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ import type { ReleaseResult } from "#types"; import type { WorkspacePackage } from "./core/workspace"; import type { ReleaseScriptsOptionsInput } from "./options"; +import process from "node:process"; +import { printReleaseError, ReleaseError } from "#shared/errors"; import { logger } from "#shared/utils"; import { prepareWorkflow as release } from "#workflows/prepare"; import { publishWorkflow as publish } from "#workflows/publish"; @@ -18,6 +20,16 @@ export interface ReleaseScripts { }; } +function withErrorBoundary(fn: () => Promise): Promise { + return fn().catch((e) => { + if (e instanceof ReleaseError) { + printReleaseError(e); + process.exit(1); + } + throw e; + }); +} + export async function createReleaseScripts(options: ReleaseScriptsOptionsInput): Promise { // Normalize options once for packages.list and packages.get const normalizedOptions = normalizeReleaseScriptsOptions(options); @@ -41,32 +53,32 @@ export async function createReleaseScripts(options: ReleaseScriptsOptionsInput): return { async verify(): Promise { - return verify(normalizedOptions); + return withErrorBoundary(() => verify(normalizedOptions)); }, async prepare(): Promise { - return release(normalizedOptions); + return withErrorBoundary(() => release(normalizedOptions)); }, async publish(): Promise { - return publish(normalizedOptions); + return withErrorBoundary(() => publish(normalizedOptions)); }, packages: { async list(): Promise { - const result = await discoverWorkspacePackages(normalizedOptions.workspaceRoot, normalizedOptions); - if (!result.ok) { - throw new Error(result.error.message); - } - return result.value; + return withErrorBoundary(async () => { + const result = await discoverWorkspacePackages(normalizedOptions.workspaceRoot, normalizedOptions); + if (!result.ok) throw new Error(result.error.message); + return result.value; + }); }, async get(packageName: string): Promise { - const result = await discoverWorkspacePackages(normalizedOptions.workspaceRoot, normalizedOptions); - if (!result.ok) { - throw new Error(result.error.message); - } - return result.value.find((p) => p.name === packageName); + return withErrorBoundary(async () => { + const result = await discoverWorkspacePackages(normalizedOptions.workspaceRoot, normalizedOptions); + if (!result.ok) throw new Error(result.error.message); + return result.value.find((p) => p.name === packageName); + }); }, }, }; diff --git a/src/operations/changelog-format.ts b/src/operations/changelog-format.ts index 9be377d..eb8f66b 100644 --- a/src/operations/changelog-format.ts +++ b/src/operations/changelog-format.ts @@ -9,7 +9,7 @@ interface FormatCommitLineOptions { authors: AuthorInfo[]; } -export function formatCommitLine({ commit, owner, repo, authors }: FormatCommitLineOptions): string { +function formatCommitLine({ commit, owner, repo, authors }: FormatCommitLineOptions): string { const commitUrl = `https://github.com/${owner}/${repo}/commit/${commit.hash}`; let line = `${commit.description}`; const references = commit.references ?? []; diff --git a/src/shared/errors.ts b/src/shared/errors.ts index 4c64304..b3dbd84 100644 --- a/src/shared/errors.ts +++ b/src/shared/errors.ts @@ -1,9 +1,5 @@ -import process from "node:process"; +import { getIsVerbose } from "#shared/utils"; import farver from "farver"; -import mri from "mri"; - -const args = mri(process.argv.slice(2)); -const isVerbose = !!args.verbose; type UnknownRecord = Record; @@ -71,7 +67,7 @@ function extractStderrLike(record: UnknownRecord): string | undefined { return undefined; } -export interface FormattedUnknownError { +interface FormattedUnknownError { message: string; stderr?: string; code?: string; @@ -148,12 +144,23 @@ export function formatUnknownError(error: unknown): FormattedUnknownError { }; } -export function exitWithError(message: string, hint?: string, cause?: unknown): never { - console.error(` ${farver.red("✖")} ${farver.bold(message)}`); +export class ReleaseError extends Error { + readonly hint?: string; + + constructor(message: string, hint?: string, cause?: unknown) { + super(message); + this.name = "ReleaseError"; + this.hint = hint; + this.cause = cause; + } +} + +export function printReleaseError(error: ReleaseError): void { + console.error(` ${farver.red("✖")} ${farver.bold(error.message)}`); - if (cause !== undefined) { - const formatted = formatUnknownError(cause); - if (formatted.message && formatted.message !== message) { + if (error.cause !== undefined) { + const formatted = formatUnknownError(error.cause); + if (formatted.message && formatted.message !== error.message) { console.error(farver.gray(` Cause: ${formatted.message}`)); } @@ -170,15 +177,17 @@ export function exitWithError(message: string, hint?: string, cause?: unknown): console.error(farver.gray(` ${formatted.stderr}`)); } - if (isVerbose && formatted.stack) { + if (getIsVerbose() && formatted.stack) { console.error(farver.gray(" Stack:")); console.error(farver.gray(` ${formatted.stack}`)); } } - if (hint) { - console.error(farver.gray(` ${hint}`)); + if (error.hint) { + console.error(farver.gray(` ${error.hint}`)); } +} - process.exit(1); +export function exitWithError(message: string, hint?: string, cause?: unknown): never { + throw new ReleaseError(message, hint, cause); } diff --git a/src/shared/types.ts b/src/shared/types.ts index b545c7a..5321f94 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1,7 +1,6 @@ import type { WorkspacePackage } from "#core/workspace"; export type BumpKind = "none" | "patch" | "minor" | "major"; -export type GlobalCommitMode = false | "dependencies" | "all"; export interface CommitTypeRule { /** diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 2bbda72..625f2f6 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -4,19 +4,41 @@ import type { } from "tinyexec"; import process from "node:process"; import readline from "node:readline"; +import { parseArgs } from "node:util"; import farver from "farver"; -import mri from "mri"; import { exec } from "tinyexec"; -export const args = mri(process.argv.slice(2)); +export const ucdjsReleaseOverridesPath = ".github/ucdjs-release.overrides.json"; -const isDryRun = !!args.dry; -const isVerbose = !!args.verbose; -const isForce = !!args.force; +function parseCLIFlags(): { dry: boolean; verbose: boolean; force: boolean } { + const { values } = parseArgs({ + args: process.argv.slice(2), + options: { + dry: { type: "boolean", short: "d", default: false }, + verbose: { type: "boolean", short: "v", default: false }, + force: { type: "boolean", short: "f", default: false }, + }, + strict: false, + }); + return { + dry: !!values.dry, + verbose: !!values.verbose, + force: !!values.force, + }; +} -export const ucdjsReleaseOverridesPath = ".github/ucdjs-release.overrides.json"; +function getIsDryRun(): boolean { + return parseCLIFlags().dry; +} -export const isCI = typeof process.env.CI === "string" && process.env.CI !== "" && process.env.CI.toLowerCase() !== "false"; +export function getIsVerbose(): boolean { + return parseCLIFlags().verbose; +} + +export function getIsCI(): boolean { + const ci = process.env.CI; + return typeof ci === "string" && ci !== "" && ci.toLowerCase() !== "false"; +} export const logger = { info: (...args: unknown[]) => { @@ -32,7 +54,7 @@ export const logger = { // Only log if verbose mode is enabled verbose: (...args: unknown[]) => { - if (!isVerbose) { + if (!getIsVerbose()) { return; } if (args.length === 0) { @@ -106,7 +128,7 @@ export async function run( }); } -export async function dryRun( +async function dryRun( bin: string, args: string[], opts?: Partial, @@ -117,10 +139,10 @@ export async function dryRun( ); } -export const runIfNotDry = isDryRun ? dryRun : run; - -if (isDryRun || isVerbose || isForce) { - logger.verbose(farver.inverse(farver.yellow(" Running with special flags "))); - logger.verbose({ isDryRun, isVerbose, isForce }); - logger.verbose(); +export async function runIfNotDry( + bin: string, + args: string[], + opts?: Partial, +): Promise { + return getIsDryRun() ? dryRun(bin, args, opts) : run(bin, args, opts); } diff --git a/src/versioning/commits.ts b/src/versioning/commits.ts index 89bfaf8..32d6ca6 100644 --- a/src/versioning/commits.ts +++ b/src/versioning/commits.ts @@ -80,7 +80,7 @@ export async function getPackageCommitsSinceTag( * @param workspaceRoot - The workspace root for path normalization * @returns true if the file is inside a package folder */ -function fileMatchesPackageFolder( +export function fileMatchesPackageFolder( file: string, packagePaths: Set, workspaceRoot: string, @@ -113,7 +113,7 @@ function fileMatchesPackageFolder( * @param packagePaths - Set of normalized package paths * @returns true if this is a global commit */ -function isGlobalCommit( +export function isGlobalCommit( workspaceRoot: string, files: string[] | undefined, packagePaths: Set, @@ -127,7 +127,7 @@ function isGlobalCommit( return !files.some((file) => fileMatchesPackageFolder(file, packagePaths, workspaceRoot)); } -const DEPENDENCY_FILES = [ +export const DEPENDENCY_FILES = [ "package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", @@ -140,7 +140,7 @@ const DEPENDENCY_FILES = [ * @param packageCommits - Map of package commits * @returns Object with oldest and newest commit SHAs, or null if no commits */ -function findCommitRange(packageCommits: Map): { oldest: string; newest: string } | null { +export function findCommitRange(packageCommits: Map): { oldest: string; newest: string } | null { let oldestCommit: string | null = null; let newestCommit: string | null = null; @@ -161,6 +161,36 @@ function findCommitRange(packageCommits: Map): { oldest: st return { oldest: oldestCommit, newest: newestCommit }; } +/** + * Filters commits to find global commits (those not touching any package folder), + * optionally further filtered to only dependency-related files. + */ +export function filterGlobalCommits( + commits: GitCommit[], + commitFilesMap: Map, + packagePaths: Set, + workspaceRoot: string, + mode: "all" | "dependencies", +): GitCommit[] { + const globalCommits = commits.filter((commit) => { + const files = commitFilesMap.get(commit.shortHash); + return files ? isGlobalCommit(workspaceRoot, files, packagePaths) : false; + }); + + if (mode === "all") { + return globalCommits; + } + + // mode === "dependencies" - only commits touching dependency files + return globalCommits.filter((commit) => { + const files = commitFilesMap.get(commit.shortHash); + if (!files) return false; + return files.some((file) => + DEPENDENCY_FILES.includes(file.startsWith("./") ? file.slice(2) : file), + ); + }); +} + /** * Get global commits for each package based on their individual commit timelines. * This solves the problem where packages with different release histories need different global commits. @@ -210,43 +240,12 @@ export async function getGlobalCommitsPerPackage( const packagePaths = new Set(allPackages.map((p) => p.path)); for (const [pkgName, commits] of packageCommits) { - const globalCommitsAffectingPackage: GitCommit[] = []; - logger.verbose("Filtering global commits for package", `${farver.bold(pkgName)} from ${farver.cyan(commits.length)} commits`); - for (const commit of commits) { - const files = commitFilesMap.value.get(commit.shortHash); - if (!files) continue; - - if (isGlobalCommit(workspaceRoot, files, packagePaths)) { - globalCommitsAffectingPackage.push(commit); - } - } - - logger.verbose("Package global commits found", `${farver.bold(pkgName)}: ${farver.cyan(globalCommitsAffectingPackage.length)} global commits`); - - if (mode === "all") { - result.set(pkgName, globalCommitsAffectingPackage); - continue; - } - - // mode === "dependencies" - const dependencyCommits: GitCommit[] = []; - - for (const commit of globalCommitsAffectingPackage) { - const files = commitFilesMap.value.get(commit.shortHash); - if (!files) continue; - - const affectsDeps = files.some((file) => DEPENDENCY_FILES.includes(file.startsWith("./") ? file.slice(2) : file)); - - if (affectsDeps) { - logger.verbose("Global commit affects dependencies", `${farver.bold(pkgName)}: commit ${farver.cyan(commit.shortHash)} affects dependencies`); - dependencyCommits.push(commit); - } - } + const filtered = filterGlobalCommits(commits, commitFilesMap.value, packagePaths, workspaceRoot, mode); - logger.verbose("Global commits affect dependencies", `${farver.bold(pkgName)}: ${farver.cyan(dependencyCommits.length)} global commits affect dependencies`); - result.set(pkgName, dependencyCommits); + logger.verbose("Package global commits found", `${farver.bold(pkgName)}: ${farver.cyan(filtered.length)} global commits`); + result.set(pkgName, filtered); } return result; diff --git a/src/versioning/version.ts b/src/versioning/version.ts index d93eb13..3fa00f4 100644 --- a/src/versioning/version.ts +++ b/src/versioning/version.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; import { confirmOverridePrompt, selectVersionPrompt } from "#core/prompts"; import { calculateBumpType, getNextVersion } from "#operations/semver"; import { determineHighestBump } from "#operations/version"; -import { isCI, logger } from "#shared/utils"; +import { getIsCI, logger } from "#shared/utils"; import { buildPackageDependencyGraph, createDependentUpdates } from "#versioning/package"; import farver from "farver"; @@ -79,12 +79,57 @@ function formatCommitsForDisplay(commits: GitCommit[]): string { return formattedCommits; } -export interface VersionOverride { +interface VersionOverride { type: BumpKind; version: string; } -export type VersionOverrides = Record; +type VersionOverrides = Record; + +/** + * Pure function that resolves version bump from commits and overrides. + * No IO, no prompts - fully testable in isolation. + */ +export function resolveAutoVersion( + pkg: WorkspacePackage, + packageCommits: GitCommit[], + globalCommits: GitCommit[], + override?: VersionOverride, +): { + determinedBump: BumpKind; + effectiveBump: BumpKind; + autoVersion: string; + resolvedVersion: string; +} { + const allCommits = [...packageCommits, ...globalCommits]; + const determinedBump = determineHighestBump(allCommits); + const effectiveBump = override?.type || determinedBump; + const autoVersion = getNextVersion(pkg.version, determinedBump); + const resolvedVersion = override?.version || autoVersion; + + return { determinedBump, effectiveBump, autoVersion, resolvedVersion }; +} + +/** + * Pure function that computes the new dependency range. + * Returns null if the dependency should not be updated (e.g. workspace:*). + */ +export function computeDependencyRange( + currentRange: string, + newVersion: string, + isPeerDependency: boolean, +): string | null { + if (currentRange === "workspace:*") { + return null; + } + + if (isPeerDependency) { + const majorVersion = newVersion.split(".")[0]; + return `>=${newVersion} <${Number(majorVersion) + 1}.0.0`; + } + + return `^${newVersion}`; +} interface CalculateVersionUpdatesOptions { workspacePackages: WorkspacePackage[]; @@ -127,17 +172,15 @@ async function calculateVersionUpdates({ const globalCommits = globalCommitsPerPackage.get(pkgName) || []; const allCommitsForPackage = [...pkgCommits, ...globalCommits]; - const determinedBump = determineHighestBump(allCommitsForPackage); const override = newOverrides[pkgName]; - const effectiveBump = override?.type || determinedBump; - const canPrompt = !isCI && showPrompt; + const { determinedBump, effectiveBump, autoVersion, resolvedVersion } = resolveAutoVersion(pkg, pkgCommits, globalCommits, override); + const canPrompt = !getIsCI() && showPrompt; if (effectiveBump === "none" && !canPrompt) { continue; } - const autoVersion = getNextVersion(pkg.version, determinedBump); - let newVersion = override?.version || autoVersion; + let newVersion = resolvedVersion; let finalBumpType: BumpKind = effectiveBump; if (canPrompt) { @@ -227,7 +270,7 @@ async function calculateVersionUpdates({ } // Second pass for manual bumps (if not in verify mode) - if (!isCI && showPrompt) { + if (!getIsCI() && showPrompt) { for (const pkg of workspacePackages) { if (processedPackages.has(pkg.name)) continue; @@ -353,23 +396,14 @@ async function updatePackageJson( const oldVersion = deps[depName]; if (!oldVersion) return; - if (oldVersion === "workspace:*") { - // Don't update workspace protocol dependencies - // PNPM will handle this automatically + const newRange = computeDependencyRange(oldVersion, depVersion, isPeerDependency); + if (newRange === null) { logger.verbose(` - Skipping workspace:* dependency: ${depName}`); return; } - if (isPeerDependency) { - // For peer dependencies, use a looser range to avoid version conflicts - // Match the major version to maintain compatibility - const majorVersion = depVersion.split(".")[0]; - deps[depName] = `>=${depVersion} <${Number(majorVersion) + 1}.0.0`; - } else { - deps[depName] = `^${depVersion}`; - } - - logger.verbose(` - Updated dependency ${depName}: ${oldVersion} → ${deps[depName]}`); + deps[depName] = newRange; + logger.verbose(` - Updated dependency ${depName}: ${oldVersion} → ${newRange}`); } // Update workspace dependencies diff --git a/test/_shared.ts b/test/_shared.ts index 726ac2d..5443c1e 100644 --- a/test/_shared.ts +++ b/test/_shared.ts @@ -44,9 +44,11 @@ export function createNormalizedReleaseOptions( packages: true, versions: true, }, + githubClient: overrides.githubClient ?? createGitHubClientStub(), npm: { provenance: true, otp: undefined, + access: "public", }, workspaceRoot: overrides.workspaceRoot ?? process.cwd(), githubToken: "test-token", @@ -67,6 +69,7 @@ export function createNormalizedReleaseOptions( enabled: true, template: "", emojis: true, + combinePrereleaseIntoFirstStable: false, }, dryRun: false, }; diff --git a/test/core/types.test.ts b/test/core/types.test.ts index ecdcef1..664067f 100644 --- a/test/core/types.test.ts +++ b/test/core/types.test.ts @@ -1,8 +1,6 @@ -import type { - GitError, - GitHubError, - WorkspaceError, -} from "../../src/core/types"; +import type { GitError } from "../../src/core/git"; +import type { GitHubError } from "../../src/core/github"; +import type { WorkspaceError } from "../../src/core/workspace"; import { describe, expect, it } from "vitest"; describe("core types", () => { diff --git a/test/shared/errors.test.ts b/test/shared/errors.test.ts new file mode 100644 index 0000000..1628eca --- /dev/null +++ b/test/shared/errors.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi } from "vitest"; +import { exitWithError, formatUnknownError, printReleaseError, ReleaseError } from "../../src/shared/errors"; + +describe("formatUnknownError", () => { + it("handles Error instances", () => { + const result = formatUnknownError(new Error("test error")); + expect(result.message).toBe("test error"); + expect(result.stack).toBeDefined(); + }); + + it("handles string errors", () => { + const result = formatUnknownError("string error"); + expect(result.message).toBe("string error"); + }); + + it("handles plain objects with message", () => { + const result = formatUnknownError({ message: "obj error" }); + expect(result.message).toBe("obj error"); + }); + + it("handles errors with stderr", () => { + const error = new Error("cmd failed"); + (error as any).stderr = "some stderr output"; + const result = formatUnknownError(error); + expect(result.stderr).toBe("some stderr output"); + }); + + it("handles errors with status code", () => { + const error = new Error("http error"); + (error as any).status = 404; + const result = formatUnknownError(error); + expect(result.status).toBe(404); + }); + + it("extracts shortMessage from tinyexec-style errors", () => { + const error = new Error("Process exited with non-zero status (1)"); + (error as any).shortMessage = "Command failed: git push"; + const result = formatUnknownError(error); + expect(result.message).toBe("Command failed: git push"); + }); + + it("handles unknown types by converting to string", () => { + const result = formatUnknownError(42); + expect(result.message).toBe("42"); + }); + + it("handles errors with code", () => { + const error = new Error("ENOENT"); + (error as any).code = "ENOENT"; + const result = formatUnknownError(error); + expect(result.code).toBe("ENOENT"); + }); +}); + +describe("releaseError", () => { + it("stores message, hint, and cause", () => { + const cause = new Error("underlying"); + const err = new ReleaseError("msg", "hint", cause); + expect(err.message).toBe("msg"); + expect(err.hint).toBe("hint"); + expect(err.cause).toBe(cause); + }); + + it("is instanceof Error", () => { + const err = new ReleaseError("test"); + expect(err).toBeInstanceOf(Error); + expect(err.name).toBe("ReleaseError"); + }); + + it("works without hint and cause", () => { + const err = new ReleaseError("simple"); + expect(err.hint).toBeUndefined(); + expect(err.cause).toBeUndefined(); + }); +}); + +describe("exitWithError", () => { + it("throws ReleaseError with message, hint, and cause", () => { + const cause = new Error("cause"); + expect(() => exitWithError("msg", "hint", cause)).toThrow(ReleaseError); + try { + exitWithError("msg", "hint", cause); + } catch (e) { + expect((e as ReleaseError).message).toBe("msg"); + expect((e as ReleaseError).hint).toBe("hint"); + expect((e as ReleaseError).cause).toBe(cause); + } + }); + + it("throws without hint", () => { + expect(() => exitWithError("msg")).toThrow(ReleaseError); + }); +}); + +describe("printReleaseError", () => { + it("prints formatted error to stderr", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + printReleaseError(new ReleaseError("Something broke", "Check config")); + const output = spy.mock.calls.map((c) => c.join(" ")).join("\n"); + expect(output).toContain("Something broke"); + expect(output).toContain("Check config"); + spy.mockRestore(); + }); + + it("prints cause details when present", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + const cause = new Error("underlying issue"); + printReleaseError(new ReleaseError("Top error", undefined, cause)); + const output = spy.mock.calls.map((c) => c.join(" ")).join("\n"); + expect(output).toContain("Top error"); + expect(output).toContain("underlying issue"); + spy.mockRestore(); + }); +}); diff --git a/test/shared/utils.test.ts b/test/shared/utils.test.ts new file mode 100644 index 0000000..530c527 --- /dev/null +++ b/test/shared/utils.test.ts @@ -0,0 +1,48 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { getIsCI } from "../../src/shared/utils"; + +describe("getIsCI", () => { + let originalCI: string | undefined; + + beforeEach(() => { + originalCI = process.env.CI; + }); + + afterEach(() => { + if (originalCI === undefined) { + delete process.env.CI; + } else { + process.env.CI = originalCI; + } + }); + + it("returns true when CI=true", () => { + process.env.CI = "true"; + expect(getIsCI()).toBe(true); + }); + + it("returns true when CI is non-empty string", () => { + process.env.CI = "1"; + expect(getIsCI()).toBe(true); + }); + + it("returns false when CI is unset", () => { + delete process.env.CI; + expect(getIsCI()).toBe(false); + }); + + it("returns false when CI=false", () => { + process.env.CI = "false"; + expect(getIsCI()).toBe(false); + }); + + it("returns false when CI is empty string", () => { + process.env.CI = ""; + expect(getIsCI()).toBe(false); + }); + + it("returns false when CI=FALSE (case insensitive)", () => { + process.env.CI = "FALSE"; + expect(getIsCI()).toBe(false); + }); +}); diff --git a/test/versioning/dependency-range.test.ts b/test/versioning/dependency-range.test.ts new file mode 100644 index 0000000..63ac5ff --- /dev/null +++ b/test/versioning/dependency-range.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { computeDependencyRange } from "../../src/versioning/version"; + +describe("computeDependencyRange", () => { + it("returns null for workspace:* ranges", () => { + expect(computeDependencyRange("workspace:*", "1.0.0", false)).toBeNull(); + }); + + it("returns ^version for regular dependencies", () => { + expect(computeDependencyRange("^0.5.0", "1.0.0", false)).toBe("^1.0.0"); + }); + + it("returns range for peer dependencies", () => { + expect(computeDependencyRange("^1.0.0", "2.0.0", true)).toBe(">=2.0.0 <3.0.0"); + }); + + it("handles 0.x peer dependencies", () => { + expect(computeDependencyRange("^0.1.0", "0.2.0", true)).toBe(">=0.2.0 <1.0.0"); + }); + + it("ignores old range value for regular deps", () => { + expect(computeDependencyRange("~0.5.0", "1.0.0", false)).toBe("^1.0.0"); + expect(computeDependencyRange(">=0.5.0", "1.0.0", false)).toBe("^1.0.0"); + }); + + it("handles peer dependency with large major", () => { + expect(computeDependencyRange("^10.0.0", "11.0.0", true)).toBe(">=11.0.0 <12.0.0"); + }); +}); diff --git a/test/versioning/global-commits.test.ts b/test/versioning/global-commits.test.ts new file mode 100644 index 0000000..fddaa6a --- /dev/null +++ b/test/versioning/global-commits.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; +import { DEPENDENCY_FILES, fileMatchesPackageFolder, filterGlobalCommits, findCommitRange, isGlobalCommit } from "../../src/versioning/commits"; +import { createCommit } from "../_shared"; + +describe("fileMatchesPackageFolder", () => { + it("matches files inside package folder", () => { + expect(fileMatchesPackageFolder("packages/a/src/index.ts", new Set(["packages/a"]), "/repo")).toBe(true); + }); + + it("does not match files outside package folders", () => { + expect(fileMatchesPackageFolder("package.json", new Set(["packages/a"]), "/repo")).toBe(false); + }); + + it("handles absolute package paths", () => { + expect(fileMatchesPackageFolder("packages/a/file.ts", new Set(["/repo/packages/a"]), "/repo")).toBe(true); + }); + + it("handles file path with leading ./", () => { + expect(fileMatchesPackageFolder("./packages/a/file.ts", new Set(["packages/a"]), "/repo")).toBe(true); + }); + + it("does not match partial folder name matches", () => { + expect(fileMatchesPackageFolder("packages/abc/file.ts", new Set(["packages/a"]), "/repo")).toBe(false); + }); +}); + +describe("isGlobalCommit", () => { + const packagePaths = new Set(["packages/a", "packages/b"]); + + it("returns true when no files touch package folders", () => { + expect(isGlobalCommit("/repo", ["package.json", "tsconfig.json"], packagePaths)).toBe(true); + }); + + it("returns false when any file touches a package folder", () => { + expect(isGlobalCommit("/repo", ["packages/a/src/index.ts", "README.md"], packagePaths)).toBe(false); + }); + + it("returns false for empty file list", () => { + expect(isGlobalCommit("/repo", [], packagePaths)).toBe(false); + }); + + it("returns false for undefined file list", () => { + expect(isGlobalCommit("/repo", undefined, packagePaths)).toBe(false); + }); +}); + +describe("findCommitRange", () => { + it("returns oldest and newest commit hashes", () => { + const map = new Map([ + ["pkg-a", [ + createCommit({ shortHash: "newest1" }), + createCommit({ shortHash: "oldest1" }), + ]], + ["pkg-b", [ + createCommit({ shortHash: "newest2" }), + createCommit({ shortHash: "oldest2" }), + ]], + ]); + const result = findCommitRange(map); + expect(result).not.toBeNull(); + expect(result!.newest).toBe("newest1"); + expect(result!.oldest).toBe("oldest2"); + }); + + it("returns null for empty map", () => { + expect(findCommitRange(new Map())).toBeNull(); + }); + + it("returns null when all packages have empty commit lists", () => { + const map = new Map([["pkg-a", []]]); + expect(findCommitRange(map)).toBeNull(); + }); +}); + +describe("filterGlobalCommits", () => { + const packagePaths = new Set(["packages/a", "packages/b"]); + + it("returns all global commits in 'all' mode", () => { + const commits = [ + createCommit({ shortHash: "c1" }), + createCommit({ shortHash: "c2" }), + createCommit({ shortHash: "c3" }), + ]; + const filesMap = new Map([ + ["c1", ["package.json"]], // global + ["c2", ["packages/a/src/index.ts"]], // touches package + ["c3", ["tsconfig.json"]], // global + ]); + + const result = filterGlobalCommits(commits, filesMap, packagePaths, "/repo", "all"); + expect(result).toHaveLength(2); + expect(result.map((c) => c.shortHash)).toEqual(["c1", "c3"]); + }); + + it("returns only dependency-touching commits in 'dependencies' mode", () => { + const commits = [ + createCommit({ shortHash: "c1" }), + createCommit({ shortHash: "c2" }), + createCommit({ shortHash: "c3" }), + ]; + const filesMap = new Map([ + ["c1", ["package.json"]], // global + dependency file + ["c2", ["packages/a/src/index.ts"]], // touches package + ["c3", ["tsconfig.json"]], // global but NOT a dependency file + ]); + + const result = filterGlobalCommits(commits, filesMap, packagePaths, "/repo", "dependencies"); + expect(result).toHaveLength(1); + expect(result[0]!.shortHash).toBe("c1"); + }); + + it("excludes commits touching package folders", () => { + const commits = [createCommit({ shortHash: "c1" })]; + const filesMap = new Map([["c1", ["packages/a/index.ts"]]]); + + const result = filterGlobalCommits(commits, filesMap, packagePaths, "/repo", "all"); + expect(result).toHaveLength(0); + }); + + it("excludes commits with no file mapping", () => { + const commits = [createCommit({ shortHash: "c1" })]; + const filesMap = new Map(); + + const result = filterGlobalCommits(commits, filesMap, packagePaths, "/repo", "all"); + expect(result).toHaveLength(0); + }); +}); + +describe("dependency files constant", () => { + it("includes expected files", () => { + expect(DEPENDENCY_FILES).toContain("package.json"); + expect(DEPENDENCY_FILES).toContain("pnpm-lock.yaml"); + expect(DEPENDENCY_FILES).toContain("pnpm-workspace.yaml"); + }); +}); diff --git a/test/versioning/package-graph.test.ts b/test/versioning/package-graph.test.ts new file mode 100644 index 0000000..f4cbfb2 --- /dev/null +++ b/test/versioning/package-graph.test.ts @@ -0,0 +1,151 @@ +import type { PackageRelease } from "#shared/types"; +import { getNextVersion } from "#operations/semver"; +import { + buildPackageDependencyGraph, + createDependentUpdates, + getAllAffectedPackages, + getPackagePublishOrder, +} from "#versioning/package"; +import { describe, expect, it } from "vitest"; +import { createWorkspacePackage } from "../_shared"; + +function createRelease(pkg: ReturnType, bump: PackageRelease["bumpType"], hasDirectChanges = true): PackageRelease { + return { + package: pkg, + currentVersion: pkg.version, + newVersion: getNextVersion(pkg.version, bump), + bumpType: bump, + hasDirectChanges, + changeKind: "auto", + }; +} + +function createWorkspaceFixture() { + const pkgD = createWorkspacePackage("/repo/packages/d", { + name: "pkg-d", + version: "1.0.0", + }); + const pkgB = createWorkspacePackage("/repo/packages/b", { + name: "pkg-b", + version: "1.0.0", + workspaceDependencies: ["pkg-d"], + }); + const pkgC = createWorkspacePackage("/repo/packages/c", { + name: "pkg-c", + version: "1.0.0", + workspaceDependencies: ["pkg-d"], + }); + const pkgA = createWorkspacePackage("/repo/packages/a", { + name: "pkg-a", + version: "1.0.0", + workspaceDependencies: ["pkg-b", "pkg-c"], + }); + const pkgE = createWorkspacePackage("/repo/packages/e", { + name: "pkg-e", + version: "1.0.0", + workspaceDevDependencies: ["pkg-a"], + }); + + return { + pkgA, + pkgB, + pkgC, + pkgD, + pkgE, + packages: [pkgA, pkgB, pkgC, pkgD, pkgE], + }; +} + +describe("package dependency graph", () => { + it("builds dependents mapping from workspace deps", () => { + const { packages } = createWorkspaceFixture(); + const graph = buildPackageDependencyGraph(packages); + + expect(graph.packages.size).toBe(5); + expect([...graph.dependents.get("pkg-d")!]).toEqual(["pkg-b", "pkg-c"]); + expect([...graph.dependents.get("pkg-b")!]).toEqual(["pkg-a"]); + expect([...graph.dependents.get("pkg-c")!]).toEqual(["pkg-a"]); + expect([...graph.dependents.get("pkg-a")!]).toEqual(["pkg-e"]); + expect([...graph.dependents.get("pkg-e")!]).toEqual([]); + }); + + it("calculates transitive affected packages", () => { + const { packages } = createWorkspaceFixture(); + const graph = buildPackageDependencyGraph(packages); + + const affectedFromD = getAllAffectedPackages(graph, new Set(["pkg-d"])); + expect([...affectedFromD].sort()).toEqual([ + "pkg-a", + "pkg-b", + "pkg-c", + "pkg-d", + "pkg-e", + ].sort()); + + const affectedFromB = getAllAffectedPackages(graph, new Set(["pkg-b"])); + expect([...affectedFromB].sort()).toEqual([ + "pkg-a", + "pkg-b", + "pkg-e", + ].sort()); + }); + + it("orders publish list by dependency level (stable)", () => { + const { packages } = createWorkspaceFixture(); + const graph = buildPackageDependencyGraph(packages); + + const order = getPackagePublishOrder(graph, new Set(["pkg-b", "pkg-c"])); + expect(order.map((entry) => `${entry.package.name}:${entry.level}`)).toEqual([ + "pkg-b:0", + "pkg-c:0", + "pkg-a:1", + ]); + + const orderFromD = getPackagePublishOrder(graph, new Set(["pkg-d"])); + expect(orderFromD.map((entry) => `${entry.package.name}:${entry.level}`)).toEqual([ + "pkg-d:0", + "pkg-b:1", + "pkg-c:1", + ]); + + const orderFromA = getPackagePublishOrder(graph, new Set(["pkg-a"])); + expect(orderFromA.map((entry) => `${entry.package.name}:${entry.level}`)).toEqual([ + "pkg-a:0", + "pkg-e:1", + ]); + }); + + it("creates dependent updates with patch bumps", () => { + const { packages, pkgB, pkgC } = createWorkspaceFixture(); + const graph = buildPackageDependencyGraph(packages); + const directUpdates = [ + createRelease(pkgB, "minor"), + createRelease(pkgC, "patch"), + ]; + + const updates = createDependentUpdates(graph, packages, directUpdates); + const byName = new Map(updates.map((update) => [update.package.name, update])); + + expect(updates).toHaveLength(4); + expect(byName.get("pkg-a")?.bumpType).toBe("patch"); + expect(byName.get("pkg-a")?.newVersion).toBe("1.0.1"); + expect(byName.get("pkg-a")?.hasDirectChanges).toBe(false); + expect(byName.get("pkg-e")?.bumpType).toBe("patch"); + expect(byName.get("pkg-e")?.newVersion).toBe("1.0.1"); + expect(byName.get("pkg-e")?.hasDirectChanges).toBe(false); + }); + + it("respects excluded packages for dependent bumps", () => { + const { packages, pkgB, pkgC } = createWorkspaceFixture(); + const graph = buildPackageDependencyGraph(packages); + const directUpdates = [ + createRelease(pkgB, "minor"), + createRelease(pkgC, "patch"), + ]; + + const updates = createDependentUpdates(graph, packages, directUpdates, new Set(["pkg-a"])); + const updatedNames = updates.map((update) => update.package.name).sort(); + + expect(updatedNames).toEqual(["pkg-b", "pkg-c", "pkg-e"].sort()); + }); +}); diff --git a/test/versioning/resolve-version.test.ts b/test/versioning/resolve-version.test.ts new file mode 100644 index 0000000..8316d1a --- /dev/null +++ b/test/versioning/resolve-version.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { resolveAutoVersion } from "../../src/versioning/version"; +import { createCommit, createWorkspacePackage } from "../_shared"; + +describe("resolveAutoVersion", () => { + it("returns none bump for empty commits", () => { + const pkg = createWorkspacePackage("/repo/a", { version: "1.0.0" }); + const result = resolveAutoVersion(pkg, [], []); + expect(result.determinedBump).toBe("none"); + expect(result.resolvedVersion).toBe("1.0.0"); + expect(result.autoVersion).toBe("1.0.0"); + }); + + it("returns minor for feat commits", () => { + const pkg = createWorkspacePackage("/repo/a", { version: "1.0.0" }); + const commits = [createCommit({ type: "feat" })]; + const result = resolveAutoVersion(pkg, commits, []); + expect(result.determinedBump).toBe("minor"); + expect(result.autoVersion).toBe("1.1.0"); + expect(result.resolvedVersion).toBe("1.1.0"); + }); + + it("returns patch for fix commits", () => { + const pkg = createWorkspacePackage("/repo/a", { version: "1.0.0" }); + const commits = [createCommit({ type: "fix" })]; + const result = resolveAutoVersion(pkg, commits, []); + expect(result.determinedBump).toBe("patch"); + expect(result.autoVersion).toBe("1.0.1"); + }); + + it("returns major for breaking change commits", () => { + const pkg = createWorkspacePackage("/repo/a", { version: "1.0.0" }); + const commits = [createCommit({ type: "feat", isBreaking: true })]; + const result = resolveAutoVersion(pkg, commits, []); + expect(result.determinedBump).toBe("major"); + expect(result.autoVersion).toBe("2.0.0"); + }); + + it("combines package and global commits", () => { + const pkg = createWorkspacePackage("/repo/a", { version: "1.0.0" }); + const pkgCommits = [createCommit({ type: "fix", shortHash: "abc0001" })]; + const globalCommits = [createCommit({ type: "feat", shortHash: "abc0002" })]; + const result = resolveAutoVersion(pkg, pkgCommits, globalCommits); + expect(result.determinedBump).toBe("minor"); + }); + + it("applies override version when present", () => { + const pkg = createWorkspacePackage("/repo/a", { version: "1.0.0" }); + const commits = [createCommit({ type: "fix" })]; + const result = resolveAutoVersion(pkg, commits, [], { type: "major", version: "2.0.0" }); + expect(result.effectiveBump).toBe("major"); + expect(result.resolvedVersion).toBe("2.0.0"); + // determinedBump still reflects the actual commits + expect(result.determinedBump).toBe("patch"); + }); + + it("applies override type without version", () => { + const pkg = createWorkspacePackage("/repo/a", { version: "1.0.0" }); + const commits = [createCommit({ type: "fix" })]; + const result = resolveAutoVersion(pkg, commits, [], { type: "minor", version: "" }); + expect(result.effectiveBump).toBe("minor"); + // resolved version falls back to auto since override version is empty + expect(result.resolvedVersion).toBe("1.0.1"); + }); + + it("uses override type when set to none (as-is)", () => { + const pkg = createWorkspacePackage("/repo/a", { version: "1.0.0" }); + const commits = [createCommit({ type: "feat" })]; + const result = resolveAutoVersion(pkg, commits, [], { type: "none", version: "1.0.0" }); + // "none" is a truthy string, so effectiveBump = "none" + expect(result.effectiveBump).toBe("none"); + expect(result.resolvedVersion).toBe("1.0.0"); + }); +}); diff --git a/test/versioning/version-dependent-updates.test.ts b/test/versioning/version-dependent-updates.test.ts new file mode 100644 index 0000000..941ad0a --- /dev/null +++ b/test/versioning/version-dependent-updates.test.ts @@ -0,0 +1,120 @@ +import type { PackageRelease } from "#shared/types"; +import { calculateAndPrepareVersionUpdates } from "#versioning/version"; +import { describe, expect, it, vi } from "vitest"; +import { createWorkspacePackage } from "../_shared"; + +vi.mock("#core/prompts", () => ({ + confirmOverridePrompt: vi.fn(), + selectVersionPrompt: vi.fn(), +})); + +describe("calculateAndPrepareVersionUpdates (dependent updates)", () => { + it("adds dependent patch bumps and preserves direct updates", async () => { + const pkgD = createWorkspacePackage("/repo/packages/d", { + name: "pkg-d", + version: "1.0.0", + }); + const pkgB = createWorkspacePackage("/repo/packages/b", { + name: "pkg-b", + version: "1.0.0", + workspaceDependencies: ["pkg-d"], + }); + const pkgC = createWorkspacePackage("/repo/packages/c", { + name: "pkg-c", + version: "1.0.0", + workspaceDependencies: ["pkg-d"], + }); + const pkgA = createWorkspacePackage("/repo/packages/a", { + name: "pkg-a", + version: "1.0.0", + workspaceDependencies: ["pkg-b", "pkg-c"], + }); + + const workspacePackages = [pkgA, pkgB, pkgC, pkgD]; + const packageCommits = new Map([ + ["pkg-b", [{ type: "feat", isConventional: true, isBreaking: false } as any]], + ["pkg-c", [{ type: "fix", isConventional: true, isBreaking: false } as any]], + ]); + const globalCommitsPerPackage = new Map(); + + const result = await calculateAndPrepareVersionUpdates({ + workspacePackages, + packageCommits, + workspaceRoot: "/repo", + showPrompt: false, + globalCommitsPerPackage, + overrides: {}, + }); + + const byName = new Map(result.allUpdates.map((update) => [update.package.name, update])); + + expect(result.allUpdates.map((update) => update.package.name).sort()).toEqual([ + "pkg-a", + "pkg-b", + "pkg-c", + ].sort()); + + expect(byName.get("pkg-b")?.bumpType).toBe("minor"); + expect(byName.get("pkg-b")?.newVersion).toBe("1.1.0"); + expect(byName.get("pkg-c")?.bumpType).toBe("patch"); + expect(byName.get("pkg-c")?.newVersion).toBe("1.0.1"); + expect(byName.get("pkg-a")?.bumpType).toBe("patch"); + expect(byName.get("pkg-a")?.newVersion).toBe("1.0.1"); + }); + + it("respects overrides that exclude dependent bumps", async () => { + const pkgD = createWorkspacePackage("/repo/packages/d", { + name: "pkg-d", + version: "1.0.0", + }); + const pkgB = createWorkspacePackage("/repo/packages/b", { + name: "pkg-b", + version: "1.0.0", + workspaceDependencies: ["pkg-d"], + }); + const pkgA = createWorkspacePackage("/repo/packages/a", { + name: "pkg-a", + version: "1.0.0", + workspaceDependencies: ["pkg-b"], + }); + + const workspacePackages = [pkgA, pkgB, pkgD]; + const packageCommits = new Map([ + ["pkg-b", [{ type: "feat", isConventional: true, isBreaking: false } as any]], + ]); + const globalCommitsPerPackage = new Map(); + + const result = await calculateAndPrepareVersionUpdates({ + workspacePackages, + packageCommits, + workspaceRoot: "/repo", + showPrompt: false, + globalCommitsPerPackage, + overrides: { + "pkg-a": { type: "none", version: "1.0.0" }, + }, + }); + + const updatedNames = result.allUpdates.map((update) => update.package.name).sort(); + expect(updatedNames).toEqual(["pkg-b"]); + }); + + it("does not add dependents when there are no direct updates", async () => { + const pkgA = createWorkspacePackage("/repo/packages/a", { + name: "pkg-a", + version: "1.0.0", + }); + const workspacePackages = [pkgA]; + + const result = await calculateAndPrepareVersionUpdates({ + workspacePackages, + packageCommits: new Map(), + workspaceRoot: "/repo", + showPrompt: false, + globalCommitsPerPackage: new Map(), + overrides: {}, + }); + + expect(result.allUpdates).toEqual([] as PackageRelease[]); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index a1c8589..a0a878d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,12 +13,7 @@ "noEmit": true, "esModuleInterop": true, "verbatimModuleSyntax": true, - "skipLibCheck": true, - "plugins": [ - { - "name": "@effect/language-service" - } - ] + "skipLibCheck": true }, "include": ["src"], "exclude": ["node_modules", "dist"]