diff --git a/.changeset/improve-glob-patterns.md b/.changeset/improve-glob-patterns.md
new file mode 100644
index 000000000..e238b3c99
--- /dev/null
+++ b/.changeset/improve-glob-patterns.md
@@ -0,0 +1,5 @@
+---
+"lingo.dev": patch
+---
+
+Add support for globstar patterns in bucket configuration and improve path matching
\ No newline at end of file
diff --git a/.claude/commands/rebase.md b/.claude/commands/rebase.md
new file mode 100644
index 000000000..f1a0faa8e
--- /dev/null
+++ b/.claude/commands/rebase.md
@@ -0,0 +1,470 @@
+---
+description: Intelligently rebase current branch onto another branch with automatic conflict resolution
+argument-hint: "[target-branch]"
+allowed-tools: Bash(git:*)
+---
+
+# Intelligent Git Rebase
+
+
+You are an expert Git engineer with deep understanding of version control, conflict resolution, and code semantics. You approach rebasing systematically: analyze the state, predict conflicts, execute the rebase, and intelligently resolve issues when they arise.
+
+Your strength is understanding code intent and making smart decisions about conflict resolution while knowing when to ask for human guidance on ambiguous cases.
+
+
+
+Successfully rebase the current branch onto the target branch with:
+- Zero data loss
+- Intelligent automatic conflict resolution where safe
+- Clear communication about what you're doing
+- Human consultation for ambiguous conflicts
+- Verification that the result works (builds/tests pass if applicable)
+
+
+## Step 1: Parse Target Branch
+
+
+Target branch for rebase: $ARGUMENTS
+
+If no target specified, default to: `main`
+
+
+**Confirm the target branch** before proceeding.
+
+## Step 2: Pre-Rebase Analysis
+
+
+Before starting the rebase, gather critical information:
+
+### 2.1 Repository State
+
+```bash
+git status
+git branch --show-current
+git log --oneline -10
+```
+
+**Verify**:
+
+- Working directory is clean (or stash changes with user permission)
+- Current branch name (don't rebase from main/master!)
+- Recent commits on current branch
+
+### 2.2 Divergence Analysis
+
+```bash
+git log --oneline ..HEAD
+git log --oneline HEAD..
+git diff ...HEAD --stat
+```
+
+**Understand**:
+
+- How many commits ahead of target
+- How many commits behind target
+- Which files have diverged (potential conflicts)
+
+### 2.3 Conflict Prediction
+
+```bash
+git diff ...HEAD --name-only
+```
+
+**Identify**:
+
+- Files modified on both branches (high conflict probability)
+- Package files (package.json, Cargo.toml, etc.) - often conflict
+- Config files (tsconfig.json, etc.)
+- Generated files (lockfiles) - may need regeneration
+
+### 2.4 Safety Checks
+
+**STOP and ask user if**:
+
+- Working directory has uncommitted changes (ask to stash or commit first)
+- Current branch is main/master/production (shouldn't rebase these)
+- More than 50 commits behind target (large rebase, confirm intent)
+- Branch has been pushed and potentially shared (force push required)
+
+**If all clear**, create a backup:
+
+```bash
+git branch backup--$(date +%s)
+```
+
+
+
+## Step 3: Execute Rebase
+
+
+Start the rebase:
+
+```bash
+git rebase
+```
+
+**Monitor the output carefully** for:
+
+- Success message (no conflicts)
+- Conflict markers (CONFLICT messages)
+- Other errors (corrupted repo, etc.)
+
+
+## Step 4: Intelligent Conflict Resolution
+
+
+If conflicts occur, handle them systematically:
+
+### 4.1 Identify All Conflicts
+
+```bash
+git status
+git diff --name-only --diff-filter=U
+```
+
+### 4.2 For Each Conflicted File
+
+**Read the conflict**:
+
+```bash
+cat
+# or use Read tool
+```
+
+**Analyze the conflict**:
+
+- What changed in our branch?
+- What changed in target branch?
+- Are changes in different sections (easy merge)?
+- Are changes to the same lines (need semantic understanding)?
+- Is this a generated file (lockfile, dist/, etc.)?
+
+### 4.3 Resolution Strategy Decision Tree
+
+**Auto-resolve if**:
+
+1. **Non-overlapping changes**: Changes are in different functions/sections
+
+ - Strategy: Keep both changes, merge intelligently
+
+2. **Formatting-only conflicts**: One side just reformatted
+
+ - Strategy: Accept the version with better formatting (target branch usually)
+
+3. **Deletion vs modification**: One side deleted, other modified
+
+ - Strategy: Usually keep the modification (deletion might be outdated)
+
+4. **Generated files**: package-lock.json, pnpm-lock.yaml, Cargo.lock, etc.
+
+ - Strategy: Accept target version, then regenerate (`pnpm install`, etc.)
+
+5. **Simple additive changes**: Both sides added different things
+
+ - Strategy: Keep both additions
+
+6. **Comment/documentation conflicts**: Non-code changes
+ - Strategy: Merge both, prefer more detailed version
+
+**Ask user for guidance if**:
+
+1. **Logic conflicts**: Both sides changed the same business logic differently
+
+ - Present both versions, explain the difference
+ - Ask which approach is correct or if manual merge needed
+
+2. **API changes**: Function signatures changed differently
+
+ - This affects other code, user needs to decide
+
+3. **Configuration conflicts**: Both sides changed same config key to different values
+
+ - User knows the intended configuration
+
+4. **Semantic conflicts**: Code that compiles but has different meaning
+
+ - Too risky to auto-resolve
+
+5. **Uncertainty**: You're not confident in automatic resolution
+ - Always err on the side of asking
+
+### 4.4 Apply Resolution
+
+**For auto-resolved conflicts**:
+
+```bash
+# Edit the file to resolve
+git add
+```
+
+**For user-consulted conflicts**:
+
+- Present the conflict clearly
+- Show both versions
+- Explain the implications
+- Wait for user decision
+- Apply their choice
+- Mark as resolved
+
+### 4.5 Continue Rebase
+
+```bash
+git rebase --continue
+```
+
+**Repeat** for each commit being rebased until complete.
+
+
+## Step 5: Post-Rebase Verification
+
+
+After successful rebase, verify everything works:
+
+### 5.1 Review Changes
+
+```bash
+git log --oneline ..HEAD
+git diff ..HEAD --stat
+```
+
+**Check**:
+
+- Commit history looks correct
+- All our commits are present
+- Changes are as expected
+
+### 5.2 Build Verification (if applicable)
+
+**For this monorepo**:
+
+```bash
+pnpm install
+pnpm build
+```
+
+**If build fails**:
+
+- Review the error
+- Likely a semantic conflict missed
+- Ask user for help or attempt fix if obvious
+
+### 5.3 Test Verification (if applicable)
+
+**Run critical tests**:
+
+```bash
+pnpm test
+# or specific test command
+```
+
+**If tests fail**:
+
+- Show which tests failed
+- This indicates integration issues from rebase
+- Ask user how to proceed
+
+### 5.4 Final Status
+
+```bash
+git status
+git log --oneline -5
+```
+
+Show user:
+
+- β
Rebase completed successfully
+- π X commits rebased onto
+- π§ Y conflicts resolved (auto: N, manual: M)
+- β
Build: passing/skipped
+- β
Tests: passing/skipped/not run
+
+
+## Step 6: Push Strategy
+
+
+After successful rebase, advise on pushing:
+
+**Analyze the situation**:
+
+```bash
+git status
+```
+
+**If branch was never pushed**:
+
+```bash
+git push -u origin
+```
+
+**If branch was previously pushed** (force push required):
+
+β οΈ **WARNING**: This rewrites history. Only safe if:
+
+- You're the only one working on this branch
+- Or you've coordinated with team
+
+Ask user: "This branch requires force push. Confirm you're the only one working on it?"
+
+**If confirmed**:
+
+```bash
+git push --force-with-lease
+```
+
+**Explain**: `--force-with-lease` is safer than `--force` (fails if remote was updated)
+
+
+---
+
+## Conflict Resolution Examples
+
+
+### Example 1: Non-overlapping Changes (Auto-resolve)
+
+**File: src/config.ts**
+
+```
+<<<<<<< HEAD
+export const API_URL = "https://api.example.com"
+export const TIMEOUT = 5000
+=======
+export const API_URL = "https://api.example.com"
+export const MAX_RETRIES = 3
+>>>>>>> main
+```
+
+**Analysis**: Both added new exports, no overlap
+**Resolution**: Keep both
+
+```typescript
+export const API_URL = "https://api.example.com";
+export const TIMEOUT = 5000;
+export const MAX_RETRIES = 3;
+```
+
+### Example 2: Generated File (Auto-resolve)
+
+**File: pnpm-lock.yaml**
+
+```
+<<<<<<< HEAD
+[... 1000 lines of lockfile conflicts ...]
+>>>>>>> main
+```
+
+**Analysis**: Generated file, both sides have valid dependencies
+**Resolution**:
+
+1. Accept target branch version: `git checkout --theirs pnpm-lock.yaml`
+2. Regenerate: `pnpm install`
+3. Stage: `git add pnpm-lock.yaml`
+
+### Example 3: Logic Conflict (Ask User)
+
+**File: src/auth.ts**
+
+```
+<<<<<<< HEAD
+function validateToken(token: string): boolean {
+ return token.length > 10 && token.startsWith("Bearer ")
+}
+=======
+function validateToken(token: string): boolean {
+ return jwt.verify(token, SECRET_KEY)
+}
+>>>>>>> main
+```
+
+**Analysis**: Completely different validation logic
+**Ask user**:
+"Conflict in auth.ts validateToken():
+
+- Your branch: Simple string validation
+- Target branch: JWT verification with secret
+
+These are fundamentally different approaches. Which should we use?
+
+1. Keep your branch (string validation)
+2. Keep target branch (JWT verification)
+3. Manual merge (explain your approach)"
+
+### Example 4: Formatting Conflict (Auto-resolve)
+
+**File: src/utils.ts**
+
+```
+<<<<<<< HEAD
+export function formatDate(date: Date): string {
+ return date.toISOString()
+}
+=======
+export function formatDate(date: Date): string { return date.toISOString() }
+>>>>>>> main
+```
+
+**Analysis**: Same logic, just formatting difference
+**Resolution**: Keep the better-formatted version (multi-line)
+
+
+---
+
+## Abort Strategy
+
+
+If at any point the rebase becomes too complex or risky:
+
+**User can abort**:
+
+```bash
+git rebase --abort
+git checkout backup-- # restore from backup
+```
+
+**You should suggest abort if**:
+
+- More than 10 files with complex conflicts
+- User is uncertain about multiple resolutions
+- Build fails after resolution attempts
+- User requests it
+
+Always remind user: "We created a backup branch, you can safely abort."
+
+
+---
+
+## Critical Success Factors
+
+
+You've done an excellent job if:
+
+β **Zero data loss**: All commits preserved, backup created
+β **Intelligent resolution**: Auto-resolved safe conflicts, asked about risky ones
+β **Clear communication**: User always knew what you were doing
+β **Working result**: Build passes, tests pass (if run)
+β **Clean history**: Linear history from target branch
+β **User confidence**: User trusts the rebase was done correctly
+
+**Never**:
+
+- Auto-resolve semantic/logic conflicts without asking
+- Proceed with rebase if working directory is dirty
+- Skip verification steps
+- Force push without user confirmation
+
+
+---
+
+## Now Begin Rebase
+
+Follow these steps in order:
+
+1. **Parse target branch** (Step 1) - Confirm the target
+2. **Pre-rebase analysis** (Step 2) - Gather information, check safety
+3. **Execute rebase** (Step 3) - Start the rebase operation
+4. **Resolve conflicts** (Step 4) - Handle conflicts intelligently
+5. **Verify result** (Step 5) - Build, test, review
+6. **Push guidance** (Step 6) - Advise on next steps
+
+**Remember**: When in doubt, ask. Rebasing rewrites historyβaccuracy matters more than speed.
+
+Begin now.
diff --git a/.claude/commands/test-cli-manual.md b/.claude/commands/test-cli-manual.md
new file mode 100644
index 000000000..d6d86dbc1
--- /dev/null
+++ b/.claude/commands/test-cli-manual.md
@@ -0,0 +1,502 @@
+---
+description: Comprehensively test the Lingo.dev CLI with exhaustive manual testing
+argument-hint: "[scope-instructions]"
+---
+
+# Manual CLI Testing - Expert QA Mode
+
+
+You are an expert QA engineer with decades of experience in CLI testing, edge case discovery, and systematic verification. Your superpower is finding bugs that humans miss. You approach testing with scientific rigor: you form hypotheses about how software should behave, design experiments to test those hypotheses, and meticulously document your findings.
+
+You never skip tests. You never assume something works. You verify everything. When you find one bug, you immediately think "what other bugs might be related to this?"
+
+
+
+Achieve 100% understanding of what does and doesn't work in the Lingo.dev CLI. This means:
+- Testing every code path you can reach
+- Discovering edge cases that aren't documented
+- Understanding the failure modes and error messages
+- Validating that success cases actually succeed (not just exit 0)
+- Going far deeper than any human tester would bother to go
+
+Success means: At the end, you can confidently explain every behavior, limitation, and quirk of the tested functionality.
+
+
+## Step 1: Parse and Understand the Scope
+
+
+The following scope has been specified for this testing session:
+
+$ARGUMENTS
+
+
+**Before proceeding, you must:**
+
+1. Parse the scope instructions above
+2. Identify which commands need testing
+3. Identify which options/combinations need testing
+4. Identify which demo projects to use
+5. Identify any integration workflows to test
+6. State your understanding back in a brief summary
+
+If the scope is vague or says "comprehensive", you should test:
+
+- ALL commands mentioned in the scope (or all commands if none specified)
+- ALL options individually + common combinations + edge case combinations
+- At minimum: json, csv, and markdown demos (use your judgment for others)
+- Integration workflows that make sense for the commands being tested
+
+## Step 2: Environment Setup
+
+
+### Build the CLI
+
+**FIRST**, verify the CLI builds successfully:
+
+```bash
+pnpm install
+pnpm --filter lingo.dev run build
+```
+
+If the build fails, report the error immediately and stopβyou cannot test a broken build.
+
+### CLI Location and Invocation
+
+- CLI path: `@packages/cli/bin/cli.mjs`
+- Invocation: `node /path/to/packages/cli/bin/cli.mjs [options]`
+- Or from demo directories: `cd /path/to/packages/cli/demo/ && node /path/to/cli.mjs `
+
+### Credentials
+
+- Use any API keys available in your environment (Lingo.dev or BYOK providers)
+- If credentials are missing: Document the error, note which tests were blocked, continue with other tests
+- **Never mock anything**βrun real commands, capture real output, report real errors
+
+### Demo Projects
+
+Available in `@packages/cli/demo/`:
+
+- `json/` - JSON format with locked keys feature
+- `csv/` - CSV format
+- `markdown/` - Markdown format
+- 20+ other formats available (android, yaml, typescript, etc.)
+
+Each demo contains:
+
+- `i18n.json` - Configuration file
+- `i18n.lock` - Translation cache/lockfile
+- Source locale files (usually in `en/` or `[locale]/` directories)
+- Target locale files
+
+
+## Step 3: Create Your Test Plan
+
+**Before executing any tests**, create a test plan:
+
+
+1. Commands to test: [list them]
+2. For each command:
+ - Required arguments/options: [list]
+ - Optional arguments/options: [list]
+ - Test strategy: [individual options, combinations, edge cases]
+3. Demo projects to use: [list]
+4. Integration workflows (if any): [list]
+5. Expected time estimate: [rough estimate]
+
+
+Present your test plan, then proceed with execution.
+
+## Step 4: Execute Tests Systematically
+
+For each command/feature in your test plan, follow this systematic approach:
+
+### Phase 1: Discovery and Documentation
+
+
+1. Run ` --help` and document:
+ - All available options/flags
+ - Required vs optional arguments
+ - Data types expected (string, number, boolean, etc.)
+ - Whether options are repeatable
+ - Any constraints mentioned (ranges, formats, etc.)
+
+2. Examine the command's purpose and expected behavior
+3. Form hypotheses about edge cases and failure modes
+
+
+### Phase 2: Baseline Testing
+
+
+Test the "happy path" first to establish a baseline:
+
+1. **Minimal invocation**: Run with only required arguments
+2. **Verify success indicators**:
+ - Exit code is 0
+ - Output messages indicate success
+ - Expected side effects occurred (files created/modified)
+ - No error messages in stderr
+3. **Document the baseline**: This is your reference point for comparison
+
+Example of exhaustive verification:
+
+```
+Command: lingo.dev run
+Exit code: 0 β
+Stdout: [document what you see]
+Stderr: [empty or document warnings]
+Files changed: [use git status or file inspection]
+Time taken: [note if unusually slow]
+```
+
+
+
+### Phase 3: Option Testing (Exhaustive)
+
+
+For EACH option:
+
+1. **Individual option test**:
+
+ - Test the option alone with a valid value
+ - Verify it changes behavior as documented
+ - Document what changed vs baseline
+
+2. **Invalid value tests**:
+
+ - Wrong type (string when number expected, etc.)
+ - Out of range (negative when positive expected, > max, etc.)
+ - Empty value
+ - Special characters / injection attempts
+ - Extremely long values
+
+3. **Boundary tests**:
+
+ - Minimum valid value
+ - Maximum valid value
+ - Just below minimum
+ - Just above maximum
+
+4. **Combination tests**:
+ - Pair with other options (especially related ones)
+ - Conflicting options (what wins?)
+ - Redundant specifications (specifying same thing twice different ways)
+
+Example exhaustive test for `--concurrency`:
+
+```
+β --concurrency 1 (minimum)
+β --concurrency 5 (mid-range)
+β --concurrency 10 (maximum)
+β --concurrency 0 (below minimum) β [document error]
+β --concurrency 11 (above maximum) β [document error]
+β --concurrency -1 (negative) β [document error]
+β --concurrency abc (non-numeric) β [document error]
+β --concurrency 1.5 (decimal) β [document error]
+β --concurrency 3 --target-locale es (combination)
+```
+
+
+
+### Phase 4: Edge Cases and Error Conditions
+
+
+Go beyond documented optionsβtry to break things:
+
+**Input validation**:
+
+- Missing required arguments
+- Extra unexpected arguments
+- Arguments in wrong order
+- Empty strings (`""`)
+- Whitespace-only input
+- Unicode/emoji in inputs
+- Paths with spaces
+- Non-existent file paths
+- Relative vs absolute paths
+
+**State-dependent tests**:
+
+- Run without authentication (if auth required)
+- Run in directory without i18n.json
+- Run with corrupted/malformed i18n.json
+- Run with invalid lockfile
+- Run in empty directory
+- Run in directory without write permissions (if testable)
+
+**Concurrent/timing issues**:
+
+- Run same command twice simultaneously (if relevant)
+- Interrupt command mid-execution (Ctrl+C)
+- Run with `--watch` and modify files
+
+**Data edge cases**:
+
+- Empty source files
+- Files with only whitespace
+- Files with invalid UTF-8
+- Extremely large files
+- Missing target locale files
+- Source and target identical
+
+
+### Phase 5: Integration Testing
+
+
+If the scope includes workflows, test command sequences:
+
+1. **State propagation**: Changes from one command affect the next
+2. **Error recovery**: What happens if middle command fails?
+3. **Idempotency**: Can you run the same sequence twice?
+
+Example workflow test:
+
+```
+Step 1: lingo.dev init β verify i18n.json created
+Step 2: lingo.dev run β verify translations generated
+Step 3: lingo.dev status β verify shows correct status
+Step 4: lingo.dev run (again) β verify idempotent (no changes)
+```
+
+
+
+### Phase 6: Verification
+
+
+For every test, verify ALL of:
+
+1. **Exit code**: 0 for success, non-zero for errors (document which codes)
+2. **Stdout**: Correct messages, formatting, data
+3. **Stderr**: Errors go to stderr, warnings documented
+4. **File system**: Use git status or ls to verify file changes
+5. **Side effects**: Auth state, config changes, lockfile updates
+6. **Consistency**: Run same command twiceβsame result?
+
+Never assume something worked because exit code is 0. Inspect the actual changes.
+
+
+## Step 5: Document Errors Thoroughly
+
+
+When you encounter errors (expected or unexpected):
+
+**For each error, document**:
+
+```
+Command: [exact command with all arguments]
+Working directory: [where you ran it]
+Exit code: [number]
+Stdout: [full output]
+Stderr: [full output]
+Context: [relevant files, config, environment state]
+Expected: [what you expected to happen]
+Actual: [what actually happened]
+Severity: [blocking / error / warning / unexpected-behavior]
+```
+
+**Then**:
+
+- Investigate: Is this a bug or expected behavior?
+- Related tests: Are there related edge cases to explore?
+- Continue: Do NOT stop testingβdocument and move on
+- Track: Keep a running list of all issues found
+
+**Never**:
+
+- Assume something is "broken" without investigation
+- Stop testing when you hit an error
+- Batch multiple errors togetherβdocument each separately
+
+
+## Step 6: Compile Final Report
+
+
+After completing all tests, provide a comprehensive report:
+
+### Executive Summary
+
+- Total tests executed: [number]
+- Success rate: [percentage]
+- Critical issues: [count]
+- Time spent: [duration]
+- Overall confidence: [high/medium/low] in the tested functionality
+
+### Detailed Findings
+
+For each tested command:
+
+**Command: ``**
+
+β
**Working Behaviors** (with examples):
+
+- Basic invocation works: ``
+- Option X works: ``
+- Combination Y+Z works: ``
+
+β **Broken/Failed Behaviors** (with full error details):
+
+- Missing arg produces unclear error: `` β ``
+- Invalid value causes crash: `` β ``
+
+β οΈ **Unexpected Behaviors** (quirks, surprises):
+
+- Command succeeds but produces no output
+- Option order affects behavior
+- Error message is confusing
+
+π **Coverage Summary**:
+
+- Options tested: X/Y (list untested ones)
+- Edge cases covered: [list]
+- Integrations tested: [list]
+
+### All Errors Encountered
+
+[Comprehensive list with full context for each]
+
+### System State After Testing
+
+- Demo projects modified: [list]
+- Config files changed: [list]
+- Any cleanup needed: [yes/no]
+
+### Recommendations
+
+Priority issues for developer attention:
+
+1. [Most critical issue]
+2. [Second priority]
+3. [Etc.]
+
+Suggested improvements:
+
+- Better error messages for X
+- Add validation for Y
+- Document behavior Z
+
+
+---
+
+## Examples of "Exhaustive" Testing
+
+
+To calibrate your understanding of "exhaustive," here are concrete examples:
+
+### Example 1: Testing `--target-locale` option
+
+**Insufficient** (what a human might do):
+
+```
+β --target-locale es
+β --target-locale fr
+Done.
+```
+
+**Exhaustive** (what you should do):
+
+```
+β --target-locale es (valid, single)
+β --target-locale fr (valid, different locale)
+β --target-locale es --target-locale fr (multiple, repeatable)
+β --target-locale xyz (invalid locale code) β documents error
+β --target-locale "" (empty) β documents error
+β --target-locale "es fr" (space-separated) β documents behavior
+β --target-locale es-MX (locale with region) β documents support
+β --target-locale 123 (numeric) β documents error
+β --target-locale ../../../etc (path traversal attempt) β documents error
+β --target-locale es (with no es config) β documents error
+β --target-locale en (source locale) β documents behavior
+β --target-locale (no value) β documents error
+```
+
+### Example 2: Testing `lingo.dev init`
+
+**Insufficient**:
+
+```
+β lingo.dev init
+Verified i18n.json was created. Done.
+```
+
+**Exhaustive**:
+
+```
+Setup: Empty directory
+β lingo.dev init β creates i18n.json with defaults
+ - Verify file exists
+ - Verify content structure
+ - Verify file permissions
+ - Verify it's valid JSON
+
+Setup: Directory with existing i18n.json
+β lingo.dev init β refuses to overwrite (error documented)
+β lingo.dev init --force β overwrites successfully
+ - Verify old content replaced
+ - Verify backup created (if applicable)
+
+Setup: Test all options
+β lingo.dev init --source en --targets es fr
+ - Verify i18n.json has correct locales
+β lingo.dev init --bucket json --paths "./[locale]/messages.json"
+ - Verify bucket config correct
+β lingo.dev init --source xyz β invalid locale error
+β lingo.dev init --paths "[invalid]" β invalid path pattern error
+
+Setup: No write permissions
+β lingo.dev init β permission error documented
+
+Setup: Non-interactive mode
+β lingo.dev init -y β no prompts, uses defaults
+
+Integration:
+β lingo.dev init β lingo.dev run β verify end-to-end workflow
+```
+
+
+
+---
+
+## Critical Success Factors
+
+
+You will know you've done an excellent job if:
+
+β **Completeness**: Every option has been tested with valid, invalid, and boundary values
+β **Depth**: You found edge cases not mentioned in documentation
+β **Rigor**: Every assertion is verified (files exist, content correct, etc.)
+β **Documentation**: Someone reading your report can reproduce every test
+β **Continuity**: You didn't stop at first errorβyou tested everything
+β **Insight**: Your report explains WHY things fail, not just that they fail
+β **Actionability**: Developers know exactly what to fix based on your report
+
+Your testing should be so thorough that a developer could confidently ship the feature based on your findings.
+
+
+---
+
+## Now Begin Testing
+
+After testing, provide:
+
+1. **Summary**: Overall findings (what works, what doesn't, any surprising behaviors)
+2. **Command-by-Command Results**: For each tested command:
+ - β
Successful behaviors (with examples)
+ - β Failed behaviors (with error details)
+ - β οΈ Unexpected behaviors (edge cases, quirks)
+3. **Errors Encountered**: All errors with full context
+4. **State of System**: Final state of demo projects and configuration files
+5. **Recommendations**: Any issues that warrant developer attention
+
+---
+
+Follow these steps in order:
+
+1. **Parse the scope** (Step 1) - Confirm your understanding
+2. **Setup environment** (Step 2) - Build CLI, verify it works
+3. **Create test plan** (Step 3) - Document what you'll test before testing
+4. **Execute tests** (Step 4) - Systematically work through your plan
+5. **Document errors** (Step 5) - Record everything that breaks
+6. **Compile report** (Step 6) - Provide comprehensive findings
+
+Remember: You are being exhaustive, not fast. Quality over speed. Completeness over convenience.
+
+Begin now.
diff --git a/packages/cli/src/cli/utils/__snapshots__/buckets.spec.ts.snap b/packages/cli/src/cli/utils/__snapshots__/buckets.spec.ts.snap
new file mode 100644
index 000000000..ef3093d66
--- /dev/null
+++ b/packages/cli/src/cli/utils/__snapshots__/buckets.spec.ts.snap
@@ -0,0 +1,239 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`getBuckets > placeholder restoration matrix > basic segment placeholder 1`] = `
+[
+ {
+ "paths": [
+ {
+ "delimiter": null,
+ "pathPattern": "src/[locale]/messages.json",
+ },
+ ],
+ "type": "json",
+ },
+]
+`;
+
+exports[`getBuckets > placeholder restoration matrix > brace expansion containing locale segment 1`] = `
+[
+ {
+ "paths": [
+ {
+ "delimiter": null,
+ "pathPattern": "src/modules/core/[locale]/strings/messages.json",
+ },
+ ],
+ "type": "json",
+ },
+]
+`;
+
+exports[`getBuckets > placeholder restoration matrix > character class adjacent to locale placeholder 1`] = `
+[
+ {
+ "paths": [
+ {
+ "delimiter": null,
+ "pathPattern": "src/files/id-[locale].json",
+ },
+ ],
+ "type": "json",
+ },
+]
+`;
+
+exports[`getBuckets > placeholder restoration matrix > deep translations path with static locale duplication 1`] = `
+[
+ {
+ "paths": [
+ {
+ "delimiter": null,
+ "pathPattern": "src/[locale]/module/en/translations/[locale].json",
+ },
+ ],
+ "type": "json",
+ },
+]
+`;
+
+exports[`getBuckets > placeholder restoration matrix > duplicated placeholder within a single segment 1`] = `
+[
+ {
+ "paths": [
+ {
+ "delimiter": null,
+ "pathPattern": "src/files/[locale]-[locale].json",
+ },
+ ],
+ "type": "json",
+ },
+]
+`;
+
+exports[`getBuckets > placeholder restoration matrix > extglob wrapping locale placeholder 1`] = `
+[
+ {
+ "paths": [
+ {
+ "delimiter": null,
+ "pathPattern": "src/modules/core-[locale].json",
+ },
+ ],
+ "type": "json",
+ },
+]
+`;
+
+exports[`getBuckets > placeholder restoration matrix > globstar after placeholder 1`] = `
+[
+ {
+ "paths": [
+ {
+ "delimiter": null,
+ "pathPattern": "src/i18n/[locale]/deep/messages.json",
+ },
+ ],
+ "type": "json",
+ },
+]
+`;
+
+exports[`getBuckets > placeholder restoration matrix > globstar before placeholder with extra segments 1`] = `
+[
+ {
+ "paths": [
+ {
+ "delimiter": null,
+ "pathPattern": "src/features/[locale]/messages.json",
+ },
+ ],
+ "type": "json",
+ },
+]
+`;
+
+exports[`getBuckets > placeholder restoration matrix > globstar before placeholder with zero extra segments 1`] = `
+[
+ {
+ "paths": [
+ {
+ "delimiter": null,
+ "pathPattern": "src/[locale]/messages.json",
+ },
+ ],
+ "type": "json",
+ },
+]
+`;
+
+exports[`getBuckets > placeholder restoration matrix > globstar surrounding placeholder with duplicate locale segment 1`] = `
+[
+ {
+ "paths": [
+ {
+ "delimiter": null,
+ "pathPattern": "src/[locale]/module/en/messages.json",
+ },
+ ],
+ "type": "json",
+ },
+]
+`;
+
+exports[`getBuckets > placeholder restoration matrix > multiple placeholder segments 1`] = `
+[
+ {
+ "paths": [
+ {
+ "delimiter": null,
+ "pathPattern": "src/[locale]/module/[locale]/messages.json",
+ },
+ ],
+ "type": "json",
+ },
+]
+`;
+
+exports[`getBuckets > placeholder restoration matrix > multiple placeholders separated by globstars 1`] = `
+[
+ {
+ "paths": [
+ {
+ "delimiter": null,
+ "pathPattern": "src/[locale]/foo/en/[locale].json",
+ },
+ ],
+ "type": "json",
+ },
+]
+`;
+
+exports[`getBuckets > placeholder restoration matrix > placeholder between single-segment wildcards 1`] = `
+[
+ {
+ "paths": [
+ {
+ "delimiter": null,
+ "pathPattern": "src/app/[locale]/file.json",
+ },
+ ],
+ "type": "json",
+ },
+]
+`;
+
+exports[`getBuckets > placeholder restoration matrix > placeholder inside file extension 1`] = `
+[
+ {
+ "paths": [
+ {
+ "delimiter": null,
+ "pathPattern": "src/messages.[locale].json",
+ },
+ ],
+ "type": "json",
+ },
+]
+`;
+
+exports[`getBuckets > placeholder restoration matrix > placeholder with prefix and suffix in same segment 1`] = `
+[
+ {
+ "paths": [
+ {
+ "delimiter": null,
+ "pathPattern": "src/files/pre[locale]post.json",
+ },
+ ],
+ "type": "json",
+ },
+]
+`;
+
+exports[`getBuckets > placeholder restoration matrix > static locale directory after placeholder 1`] = `
+[
+ {
+ "paths": [
+ {
+ "delimiter": null,
+ "pathPattern": "src/[locale]/module/en/messages.json",
+ },
+ ],
+ "type": "json",
+ },
+]
+`;
+
+exports[`getBuckets > placeholder restoration matrix > static locale directory before placeholder 1`] = `
+[
+ {
+ "paths": [
+ {
+ "delimiter": null,
+ "pathPattern": "src/en/module/[locale]/messages.json",
+ },
+ ],
+ "type": "json",
+ },
+]
+`;
diff --git a/packages/cli/src/cli/utils/buckets.spec.ts b/packages/cli/src/cli/utils/buckets.spec.ts
index ec2be41fb..793abb3c4 100644
--- a/packages/cli/src/cli/utils/buckets.spec.ts
+++ b/packages/cli/src/cli/utils/buckets.spec.ts
@@ -1,5 +1,5 @@
-import { describe, it, expect, vi } from "vitest";
-import { getBuckets } from "./buckets";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { getBuckets, parsePatternTemplate } from "./buckets";
import { glob, Path } from "glob";
vi.mock("glob", () => ({
@@ -8,7 +8,96 @@ vi.mock("glob", () => ({
},
}));
+describe("parsePatternTemplate", () => {
+ it("captures globstars and placeholder segments", () => {
+ expect(parsePatternTemplate("src/**/[locale]/**/messages.json")).toEqual([
+ {
+ kind: "segment",
+ original: "src",
+ parts: [{ kind: "literal", value: "src" }],
+ hasPlaceholder: false,
+ hasGlob: false,
+ },
+ { kind: "globstar", original: "**" },
+ {
+ kind: "segment",
+ original: "[locale]",
+ parts: [{ kind: "placeholder", name: "locale" }],
+ hasPlaceholder: true,
+ hasGlob: false,
+ },
+ { kind: "globstar", original: "**" },
+ {
+ kind: "segment",
+ original: "messages.json",
+ parts: [{ kind: "literal", value: "messages.json" }],
+ hasPlaceholder: false,
+ hasGlob: false,
+ },
+ ]);
+ });
+
+ it("identifies placeholders embedded within segments", () => {
+ expect(parsePatternTemplate("src/messages.[locale].json")).toEqual([
+ {
+ kind: "segment",
+ original: "src",
+ parts: [{ kind: "literal", value: "src" }],
+ hasPlaceholder: false,
+ hasGlob: false,
+ },
+ {
+ kind: "segment",
+ original: "messages.[locale].json",
+ parts: [
+ { kind: "literal", value: "messages." },
+ { kind: "placeholder", name: "locale" },
+ { kind: "literal", value: ".json" },
+ ],
+ hasPlaceholder: true,
+ hasGlob: false,
+ },
+ ]);
+ });
+
+ it("marks glob syntax distinct from placeholders", () => {
+ expect(
+ parsePatternTemplate("src/modules/@(core|marketing)-[locale].json"),
+ ).toEqual([
+ {
+ kind: "segment",
+ original: "src",
+ parts: [{ kind: "literal", value: "src" }],
+ hasPlaceholder: false,
+ hasGlob: false,
+ },
+ {
+ kind: "segment",
+ original: "modules",
+ parts: [{ kind: "literal", value: "modules" }],
+ hasPlaceholder: false,
+ hasGlob: false,
+ },
+ {
+ kind: "segment",
+ original: "@(core|marketing)-[locale].json",
+ parts: [
+ { kind: "glob", value: "@(core|marketing)-" },
+ { kind: "placeholder", name: "locale" },
+ { kind: "literal", value: ".json" },
+ ],
+ hasPlaceholder: true,
+ hasGlob: true,
+ },
+ ]);
+ });
+});
+
describe("getBuckets", () => {
+ beforeEach(() => {
+ vi.mocked(glob.sync).mockReset();
+ });
+
const makeI18nConfig = (include: any[]) => ({
$schema: "https://lingo.dev/schema/i18n.json",
version: 0,
@@ -167,6 +256,452 @@ describe("getBuckets", () => {
},
]);
});
+
+ it("restores locale placeholder when using recursive globstar patterns", () => {
+ mockGlobSync([
+ "src/modules/core/auth/en/strings/messages.json",
+ "src/modules/marketing/en/strings/dashboard.json",
+ ]);
+
+ const i18nConfig = makeI18nConfig([
+ "src/modules/**/[locale]/strings/*.json",
+ ]);
+ const buckets = getBuckets(i18nConfig);
+
+ expect(buckets).toEqual([
+ {
+ type: "json",
+ paths: [
+ {
+ pathPattern: "src/modules/core/auth/[locale]/strings/messages.json",
+ delimiter: null,
+ },
+ {
+ pathPattern:
+ "src/modules/marketing/[locale]/strings/dashboard.json",
+ delimiter: null,
+ },
+ ],
+ },
+ ]);
+ });
+
+ it("restores placeholder when extglob wraps the locale segment", () => {
+ mockGlobSync(["src/modules/core-en.json", "src/modules/marketing-en.json"]);
+
+ const i18nConfig = makeI18nConfig([
+ "src/modules/@(core|marketing)-[locale].json",
+ ]);
+ const buckets = getBuckets(i18nConfig);
+
+ expect(buckets).toEqual([
+ {
+ type: "json",
+ paths: [
+ { pathPattern: "src/modules/core-[locale].json", delimiter: null },
+ {
+ pathPattern: "src/modules/marketing-[locale].json",
+ delimiter: null,
+ },
+ ],
+ },
+ ]);
+ });
+
+ it("restores placeholder when brace expansion surrounds locale segment", () => {
+ mockGlobSync([
+ "src/modules/core/en/strings/messages.json",
+ "src/modules/marketing/en/strings/dashboard.json",
+ ]);
+
+ const i18nConfig = makeI18nConfig([
+ "src/modules/{core,marketing}/[locale]/strings/*.json",
+ ]);
+ const buckets = getBuckets(i18nConfig);
+
+ expect(buckets).toEqual([
+ {
+ type: "json",
+ paths: [
+ {
+ pathPattern: "src/modules/core/[locale]/strings/messages.json",
+ delimiter: null,
+ },
+ {
+ pathPattern:
+ "src/modules/marketing/[locale]/strings/dashboard.json",
+ delimiter: null,
+ },
+ ],
+ },
+ ]);
+ });
+
+ it("preserves glob character classes around locale placeholder", () => {
+ mockGlobSync(["src/files/id-en.json"]);
+
+ const i18nConfig = makeI18nConfig(["src/files/??-[locale].json"]);
+ const buckets = getBuckets(i18nConfig);
+
+ expect(buckets).toEqual([
+ {
+ type: "json",
+ paths: [
+ {
+ pathPattern: "src/files/id-[locale].json",
+ delimiter: null,
+ },
+ ],
+ },
+ ]);
+ });
+
+ it("supports globstar at the beginning of the pattern", () => {
+ mockGlobSync([
+ "src/modules/core/en/messages.json",
+ "src/modules/marketing/en/dashboard.json",
+ ]);
+
+ const i18nConfig = makeI18nConfig(["**/[locale]/*.json"]);
+ const buckets = getBuckets(i18nConfig);
+
+ expect(buckets).toEqual([
+ {
+ type: "json",
+ paths: [
+ {
+ pathPattern: "src/modules/core/[locale]/messages.json",
+ delimiter: null,
+ },
+ {
+ pathPattern: "src/modules/marketing/[locale]/dashboard.json",
+ delimiter: null,
+ },
+ ],
+ },
+ ]);
+ });
+
+ it("supports multiple globstars surrounding the locale segment", () => {
+ mockGlobSync([
+ "src/modules/core/services/en/api/messages.json",
+ "src/modules/marketing/en/email/templates/messages.json",
+ ]);
+
+ const i18nConfig = makeI18nConfig(["src/**/[locale]/**/messages.json"]);
+ const buckets = getBuckets(i18nConfig);
+
+ expect(buckets).toEqual([
+ {
+ type: "json",
+ paths: [
+ {
+ pathPattern: "src/modules/core/services/[locale]/api/messages.json",
+ delimiter: null,
+ },
+ {
+ pathPattern:
+ "src/modules/marketing/[locale]/email/templates/messages.json",
+ delimiter: null,
+ },
+ ],
+ },
+ ]);
+ });
+
+ it("supports globstar segments after the locale placeholder", () => {
+ mockGlobSync(["src/i18n/en/deep/messages.json"]);
+
+ const i18nConfig = makeI18nConfig(["src/i18n/[locale]/**/messages.json"]);
+ const buckets = getBuckets(i18nConfig);
+
+ expect(buckets).toEqual([
+ {
+ type: "json",
+ paths: [
+ {
+ pathPattern: "src/i18n/[locale]/deep/messages.json",
+ delimiter: null,
+ },
+ ],
+ },
+ ]);
+ });
+
+ it("supports globstar leading directly into the locale file name", () => {
+ mockGlobSync(["src/en.json", "src/translations/en.json"]);
+
+ const i18nConfig = makeI18nConfig(["**/[locale].json"]);
+ const buckets = getBuckets(i18nConfig);
+
+ expect(buckets).toEqual([
+ {
+ type: "json",
+ paths: [
+ { pathPattern: "src/[locale].json", delimiter: null },
+ {
+ pathPattern: "src/translations/[locale].json",
+ delimiter: null,
+ },
+ ],
+ },
+ ]);
+ });
+
+ it("supports trailing globstar before the file extension", () => {
+ mockGlobSync(["src/files/en/report.json", "src/files/en/app.json"]);
+
+ const i18nConfig = makeI18nConfig(["src/files/[locale]/**.json"]);
+ const buckets = getBuckets(i18nConfig);
+
+ expect(buckets).toEqual([
+ {
+ type: "json",
+ paths: [
+ {
+ pathPattern: "src/files/[locale]/report.json",
+ delimiter: null,
+ },
+ {
+ pathPattern: "src/files/[locale]/app.json",
+ delimiter: null,
+ },
+ ],
+ },
+ ]);
+ });
+
+ it("handles consecutive globstars before the locale segment", () => {
+ mockGlobSync(["src/a/b/en/messages.json", "src/en/messages.json"]);
+
+ const i18nConfig = makeI18nConfig(["src/**/**/[locale]/messages.json"]);
+ const buckets = getBuckets(i18nConfig);
+
+ expect(buckets).toEqual([
+ {
+ type: "json",
+ paths: [
+ {
+ pathPattern: "src/a/b/[locale]/messages.json",
+ delimiter: null,
+ },
+ {
+ pathPattern: "src/[locale]/messages.json",
+ delimiter: null,
+ },
+ ],
+ },
+ ]);
+ });
+
+ it("prioritizes the earliest matching locale segment when duplicates exist", () => {
+ mockGlobSync(["src/en/module/en/messages.json"]);
+
+ const i18nConfig = makeI18nConfig(["src/**/[locale]/**/messages.json"]);
+ const buckets = getBuckets(i18nConfig);
+
+ expect(buckets).toEqual([
+ {
+ type: "json",
+ paths: [
+ {
+ pathPattern: "src/[locale]/module/en/messages.json",
+ delimiter: null,
+ },
+ ],
+ },
+ ]);
+ });
+
+ it("deduplicates overlapping include patterns", () => {
+ mockGlobSync(["src/i18n/en.json"]);
+ mockGlobSync(["src/i18n/en.json"]);
+
+ const i18nConfig = makeI18nConfig([
+ "src/i18n/**/[locale].json",
+ "src/i18n/[locale].json",
+ ]);
+ const buckets = getBuckets(i18nConfig);
+
+ expect(buckets).toEqual([
+ {
+ type: "json",
+ paths: [{ pathPattern: "src/i18n/[locale].json", delimiter: null }],
+ },
+ ]);
+ });
+
+ it("keeps distinct entries for matching paths with different delimiters", () => {
+ mockGlobSync(["src/i18n/en.json"]);
+ mockGlobSync(["src/i18n/en.json"]);
+
+ const i18nConfig = makeI18nConfig([
+ { path: "src/i18n/[locale].json", delimiter: "-" },
+ { path: "src/i18n/[locale].json", delimiter: "_" },
+ ]);
+ const buckets = getBuckets(i18nConfig);
+
+ expect(buckets).toEqual([
+ {
+ type: "json",
+ paths: [
+ { pathPattern: "src/i18n/[locale].json", delimiter: "-" },
+ { pathPattern: "src/i18n/[locale].json", delimiter: "_" },
+ ],
+ },
+ ]);
+ });
+
+ it("excludes entries matching both path pattern and delimiter", () => {
+ mockGlobSync(["src/i18n/en.json"]);
+ mockGlobSync(["src/i18n/en.json"]);
+ mockGlobSync(["src/i18n/en.json"]);
+
+ const i18nConfig = makeI18nConfig([
+ { path: "src/i18n/[locale].json", delimiter: "-" },
+ { path: "src/i18n/[locale].json", delimiter: "_" },
+ ]);
+ i18nConfig.buckets.json.exclude = [
+ { path: "src/i18n/[locale].json", delimiter: "-" },
+ ];
+
+ const buckets = getBuckets(i18nConfig);
+
+ expect(buckets).toEqual([
+ {
+ type: "json",
+ paths: [{ pathPattern: "src/i18n/[locale].json", delimiter: "_" }],
+ },
+ ]);
+ });
+
+ it("restores placeholder when locale appears multiple times in a segment", () => {
+ mockGlobSync(["src/files/en-en.json"]);
+
+ const i18nConfig = makeI18nConfig(["src/files/[locale]-[locale].json"]);
+ const buckets = getBuckets(i18nConfig);
+
+ expect(buckets).toEqual([
+ {
+ type: "json",
+ paths: [
+ { pathPattern: "src/files/[locale]-[locale].json", delimiter: null },
+ ],
+ },
+ ]);
+ });
+
+ it("throws when pattern resolves outside of the current working directory", () => {
+ const i18nConfig = makeI18nConfig(["../outside/[locale].json"]);
+
+ expect(() => getBuckets(i18nConfig)).toThrowError(
+ /Invalid path pattern: \.{2}\//,
+ );
+ });
+
+ describe("placeholder restoration matrix", () => {
+ const cases: Array<{
+ name: string;
+ include: any[];
+ globResults: string[][];
+ }> = [
+ {
+ name: "basic segment placeholder",
+ include: ["src/[locale]/messages.json"],
+ globResults: [["src/en/messages.json"]],
+ },
+ {
+ name: "placeholder inside file extension",
+ include: ["src/messages.[locale].json"],
+ globResults: [["src/messages.en.json"]],
+ },
+ {
+ name: "placeholder with prefix and suffix in same segment",
+ include: ["src/files/pre[locale]post.json"],
+ globResults: [["src/files/preenpost.json"]],
+ },
+ {
+ name: "duplicated placeholder within a single segment",
+ include: ["src/files/[locale]-[locale].json"],
+ globResults: [["src/files/en-en.json"]],
+ },
+ {
+ name: "multiple placeholder segments",
+ include: ["src/[locale]/module/[locale]/messages.json"],
+ globResults: [["src/en/module/en/messages.json"]],
+ },
+ {
+ name: "static locale directory before placeholder",
+ include: ["src/en/module/[locale]/messages.json"],
+ globResults: [["src/en/module/en/messages.json"]],
+ },
+ {
+ name: "static locale directory after placeholder",
+ include: ["src/[locale]/module/en/messages.json"],
+ globResults: [["src/en/module/en/messages.json"]],
+ },
+ {
+ name: "globstar before placeholder with extra segments",
+ include: ["src/**/[locale]/messages.json"],
+ globResults: [["src/features/en/messages.json"]],
+ },
+ {
+ name: "globstar before placeholder with zero extra segments",
+ include: ["src/**/[locale]/messages.json"],
+ globResults: [["src/en/messages.json"]],
+ },
+ {
+ name: "globstar after placeholder",
+ include: ["src/i18n/[locale]/**/messages.json"],
+ globResults: [["src/i18n/en/deep/messages.json"]],
+ },
+ {
+ name: "globstar surrounding placeholder with duplicate locale segment",
+ include: ["src/**/[locale]/**/messages.json"],
+ globResults: [["src/en/module/en/messages.json"]],
+ },
+ {
+ name: "multiple placeholders separated by globstars",
+ include: ["src/**/[locale]/**/[locale].json"],
+ globResults: [["src/en/foo/en/en.json"]],
+ },
+ {
+ name: "extglob wrapping locale placeholder",
+ include: ["src/modules/@(core|marketing)-[locale].json"],
+ globResults: [["src/modules/core-en.json"]],
+ },
+ {
+ name: "brace expansion containing locale segment",
+ include: ["src/modules/{core,marketing}/[locale]/strings/*.json"],
+ globResults: [["src/modules/core/en/strings/messages.json"]],
+ },
+ {
+ name: "character class adjacent to locale placeholder",
+ include: ["src/files/??-[locale].json"],
+ globResults: [["src/files/id-en.json"]],
+ },
+ {
+ name: "placeholder between single-segment wildcards",
+ include: ["src/*/[locale]/file.json"],
+ globResults: [["src/app/en/file.json"]],
+ },
+ {
+ name: "deep translations path with static locale duplication",
+ include: ["src/**/[locale]/**/translations/[locale].json"],
+ globResults: [["src/en/module/en/translations/en.json"]],
+ },
+ ];
+
+ cases.forEach(({ name, include, globResults }) => {
+ it(name, () => {
+ mockGlobSync(...globResults);
+ const i18nConfig = makeI18nConfig(include);
+ const buckets = getBuckets(i18nConfig);
+
+ expect(buckets).toMatchSnapshot();
+ });
+ });
+ });
});
function mockGlobSync(...args: string[][]) {
diff --git a/packages/cli/src/cli/utils/buckets.ts b/packages/cli/src/cli/utils/buckets.ts
index 962030c09..a9d5bfbba 100644
--- a/packages/cli/src/cli/utils/buckets.ts
+++ b/packages/cli/src/cli/utils/buckets.ts
@@ -1,6 +1,7 @@
import _ from "lodash";
import path from "path";
import { glob } from "glob";
+import { minimatch } from "minimatch";
import { CLIError } from "./errors";
import {
I18nConfig,
@@ -11,6 +12,245 @@ import {
import { bucketTypeSchema } from "@lingo.dev/_spec";
import Z from "zod";
+export type TemplateSegmentPart =
+ | { kind: "literal"; value: string }
+ | { kind: "glob"; value: string }
+ | { kind: "placeholder"; name: string };
+
+export type TemplateSegment =
+ | { kind: "globstar"; original: "**" }
+ | {
+ kind: "segment";
+ original: string;
+ parts: TemplateSegmentPart[];
+ hasPlaceholder: boolean;
+ hasGlob: boolean;
+ };
+
+const LOCALE_PLACEHOLDER = "[locale]";
+const GLOB_CHARS_REGEX = /[\*\?\[\]\{\}\(\)!+@,]/;
+
+function isGlobPattern(value: string) {
+ return GLOB_CHARS_REGEX.test(value);
+}
+
+function flushBuffer(
+ parts: TemplateSegmentPart[],
+ buffer: string,
+): string {
+ if (!buffer) {
+ return "";
+ }
+ if (isGlobPattern(buffer)) {
+ parts.push({ kind: "glob", value: buffer });
+ } else {
+ parts.push({ kind: "literal", value: buffer });
+ }
+ return "";
+}
+
+export function parsePatternTemplate(pattern: string): TemplateSegment[] {
+ const normalized = pattern.replace(/\\/g, "/");
+ const rawSegments = normalized.split("/");
+
+ return rawSegments.map((segment) => {
+ if (segment === "**") {
+ return { kind: "globstar", original: "**" };
+ }
+
+ const parts: TemplateSegmentPart[] = [];
+ let buffer = "";
+ let index = 0;
+ while (index < segment.length) {
+ if (segment.startsWith(LOCALE_PLACEHOLDER, index)) {
+ buffer = flushBuffer(parts, buffer);
+ parts.push({ kind: "placeholder", name: "locale" });
+ index += LOCALE_PLACEHOLDER.length;
+ continue;
+ }
+ buffer += segment[index];
+ index += 1;
+ }
+ flushBuffer(parts, buffer);
+
+ const hasPlaceholder = parts.some((part) => part.kind === "placeholder");
+ const hasGlob = parts.some((part) => part.kind === "glob");
+
+ return {
+ kind: "segment",
+ original: segment,
+ parts,
+ hasPlaceholder,
+ hasGlob,
+ };
+ });
+}
+
+function segmentToConcretePattern(
+ segment: Extract,
+ locale: string,
+): string {
+ if (!segment.hasPlaceholder) {
+ return segment.original;
+ }
+ return segment.original.split(LOCALE_PLACEHOLDER).join(locale);
+}
+
+function segmentMatchesSource(
+ segment: Extract,
+ source: string,
+ locale: string,
+): boolean {
+ if (!segment.hasPlaceholder && !segment.hasGlob) {
+ return source === segment.original;
+ }
+ const concrete = segmentToConcretePattern(segment, locale);
+ return minimatch(source, concrete, { dot: true });
+}
+
+function renderSegment(
+ segment: Extract,
+ source: string,
+ locale: string,
+): string | null {
+ const memo = new Map();
+
+ const dfs = (partIndex: number, position: number): string | null => {
+ const memoKey = `${partIndex}|${position}`;
+ if (memo.has(memoKey)) {
+ return memo.get(memoKey)!;
+ }
+ if (partIndex === segment.parts.length) {
+ const result = position === source.length ? "" : null;
+ memo.set(memoKey, result);
+ return result;
+ }
+
+ const part = segment.parts[partIndex];
+
+ if (part.kind === "literal") {
+ if (source.startsWith(part.value, position)) {
+ const rest = dfs(partIndex + 1, position + part.value.length);
+ if (rest !== null) {
+ const result = part.value + rest;
+ memo.set(memoKey, result);
+ return result;
+ }
+ }
+ memo.set(memoKey, null);
+ return null;
+ }
+
+ if (part.kind === "placeholder") {
+ if (source.startsWith(locale, position)) {
+ const rest = dfs(partIndex + 1, position + locale.length);
+ if (rest !== null) {
+ const result = `${LOCALE_PLACEHOLDER}${rest}`;
+ memo.set(memoKey, result);
+ return result;
+ }
+ }
+ memo.set(memoKey, null);
+ return null;
+ }
+
+ for (let length = 0; position + length <= source.length; length += 1) {
+ const fragment = source.slice(position, position + length);
+ if (!minimatch(fragment, part.value, { dot: true })) {
+ continue;
+ }
+ const rest = dfs(partIndex + 1, position + length);
+ if (rest !== null) {
+ const result = fragment + rest;
+ memo.set(memoKey, result);
+ return result;
+ }
+ }
+
+ memo.set(memoKey, null);
+ return null;
+ };
+
+ return dfs(0, 0);
+}
+
+function buildOutputSegment(
+ segment: Extract,
+ source: string,
+ locale: string,
+): string {
+ if (segment.hasPlaceholder) {
+ return renderSegment(segment, source, locale) ?? segment.original;
+ }
+ if (segment.hasGlob) {
+ return source;
+ }
+ return segment.original;
+}
+
+function matchTemplateToSource(
+ template: TemplateSegment[],
+ sourceSegments: string[],
+ locale: string,
+): string[] | null {
+ const memo = new Map();
+
+ const dfs = (templateIndex: number, sourceIndex: number): string[] | null => {
+ const memoKey = `${templateIndex}|${sourceIndex}`;
+ if (memo.has(memoKey)) {
+ return memo.get(memoKey)!;
+ }
+ if (templateIndex === template.length) {
+ const result = sourceIndex === sourceSegments.length ? [] : null;
+ memo.set(memoKey, result);
+ return result;
+ }
+
+ const segment = template[templateIndex];
+
+ if (segment.kind === "globstar") {
+ for (let consume = 0; consume <= sourceSegments.length - sourceIndex; consume += 1) {
+ const rest = dfs(templateIndex + 1, sourceIndex + consume);
+ if (rest) {
+ const consumed = sourceSegments.slice(sourceIndex, sourceIndex + consume);
+ const combined = [...consumed, ...rest];
+ memo.set(memoKey, combined);
+ return combined;
+ }
+ }
+ memo.set(memoKey, null);
+ return null;
+ }
+
+ if (sourceIndex >= sourceSegments.length) {
+ memo.set(memoKey, null);
+ return null;
+ }
+
+ if (!segmentMatchesSource(segment, sourceSegments[sourceIndex], locale)) {
+ memo.set(memoKey, null);
+ return null;
+ }
+
+ const rest = dfs(templateIndex + 1, sourceIndex + 1);
+ if (!rest) {
+ memo.set(memoKey, null);
+ return null;
+ }
+
+ const current = buildOutputSegment(
+ segment,
+ sourceSegments[sourceIndex],
+ locale,
+ );
+ const combined = [current, ...rest];
+ memo.set(memoKey, combined);
+ return combined;
+ };
+
+ return dfs(0, 0);
+}
+
type BucketConfig = {
type: Z.infer;
paths: Array<{ pathPattern: string; delimiter?: LocaleDelimiter }>;
@@ -70,6 +310,11 @@ function extractPathPatterns(
delimiter: pattern.delimiter,
})),
);
+ const getUniqKey = (item: {
+ pathPattern: string;
+ delimiter?: LocaleDelimiter;
+ }) => `${item.pathPattern}::${item.delimiter ?? ""}`;
+ const uniqueIncludedPatterns = _.uniqBy(includedPatterns, getUniqKey);
const excludedPatterns = exclude?.flatMap((pattern) =>
expandPlaceholderedGlob(
pattern.path,
@@ -79,10 +324,13 @@ function extractPathPatterns(
delimiter: pattern.delimiter,
})),
);
+ const uniqueExcludedPatterns = excludedPatterns
+ ? _.uniqBy(excludedPatterns, getUniqKey)
+ : [];
const result = _.differenceBy(
- includedPatterns,
- excludedPatterns ?? [],
- (item) => item.pathPattern,
+ uniqueIncludedPatterns,
+ uniqueExcludedPatterns,
+ getUniqKey,
);
return result;
}
@@ -110,28 +358,14 @@ function expandPlaceholderedGlob(
});
}
- // Throw error if pathPattern contains "**" β we don't support recursive path patterns
- if (pathPattern.includes("**")) {
- throw new CLIError({
- message: `Invalid path pattern: ${pathPattern}. Recursive path patterns are not supported.`,
- docUrl: "invalidPathPattern",
- });
- }
-
- // Break down path pattern into parts
- const pathPatternChunks = pathPattern.split(path.sep);
- // Find the index of the segment containing "[locale]"
- const localeSegmentIndexes = pathPatternChunks.reduce(
- (indexes, segment, index) => {
- if (segment.includes("[locale]")) {
- indexes.push(index);
- }
- return indexes;
- },
- [] as number[],
+ const template = parsePatternTemplate(pathPattern.split(path.sep).join("/"));
+ const normalizedLocale =
+ process.platform === "win32" ? sourceLocale.toLowerCase() : sourceLocale;
+ // substitute [locale] in pathPattern with normalized locale
+ const sourcePathPattern = pathPattern.replaceAll(
+ /\[locale\]/g,
+ normalizedLocale,
);
- // substitute [locale] in pathPattern with sourceLocale
- const sourcePathPattern = pathPattern.replaceAll(/\[locale\]/g, sourceLocale);
// Convert to Unix-style for Windows compatibility
const unixStylePattern = sourcePathPattern.replace(/\\/g, "/");
@@ -153,27 +387,18 @@ function expandPlaceholderedGlob(
sourcePath.replace(/\//g, path.sep),
);
const sourcePathChunks = normalizedSourcePath.split(path.sep);
- localeSegmentIndexes.forEach((localeSegmentIndex) => {
- // Find the position of the "[locale]" placeholder within the segment
- const pathPatternChunk = pathPatternChunks[localeSegmentIndex];
- const sourcePathChunk = sourcePathChunks[localeSegmentIndex];
- const regexp = new RegExp(
- "(" +
- pathPatternChunk
- .replaceAll(".", "\\.")
- .replaceAll("*", ".*")
- .replace("[locale]", `)${sourceLocale}(`) +
- ")",
- );
- const match = sourcePathChunk.match(regexp);
- if (match) {
- const [, prefix, suffix] = match;
- const placeholderedSegment = prefix + "[locale]" + suffix;
- sourcePathChunks[localeSegmentIndex] = placeholderedSegment;
- }
- });
- const placeholderedPath = sourcePathChunks.join(path.sep);
- return placeholderedPath;
+ const matchedSegments = matchTemplateToSource(
+ template,
+ sourcePathChunks,
+ normalizedLocale,
+ );
+ if (!matchedSegments) {
+ throw new CLIError({
+ message: `Pattern "${_pathPattern}" does not map cleanly to matched path "${sourcePath}". Adjust the glob so the placeholder segments can be restored without ambiguity.`,
+ docUrl: "invalidPlaceholderMapping",
+ });
+ }
+ return matchedSegments.join(path.sep);
});
// return the placeholdered paths
return placeholderedPaths;
diff --git a/packages/cli/src/cli/utils/errors.ts b/packages/cli/src/cli/utils/errors.ts
index d25f2ae23..9b0f5ffef 100644
--- a/packages/cli/src/cli/utils/errors.ts
+++ b/packages/cli/src/cli/utils/errors.ts
@@ -13,6 +13,7 @@ export const docLinks = {
androidResouceError: "https://lingo.dev/cli",
invalidBucketType: "https://lingo.dev/cli",
invalidStringDict: "https://lingo.dev/cli",
+ invalidPlaceholderMapping: "https://lingo.dev/cli",
};
type DocLinkKeys = keyof typeof docLinks;
diff --git a/packages/cli/src/locale-codes/index.ts b/packages/cli/src/locale-codes/index.ts
index eef362f09..a3c290a65 100644
--- a/packages/cli/src/locale-codes/index.ts
+++ b/packages/cli/src/locale-codes/index.ts
@@ -1,3 +1,3 @@
// Re-export everything but with type checking
export type * from "@lingo.dev/_locales";
-export * from "@lingo.dev/_locales";
\ No newline at end of file
+export * from "@lingo.dev/_locales";
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 53edd112c..7a45a9475 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8346,10 +8346,6 @@ packages:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
- picomatch@4.0.2:
- resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==}
- engines: {node: '>=12'}
-
picomatch@4.0.3:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
@@ -13567,10 +13563,10 @@ snapshots:
'@rollup/pluginutils': 5.1.4(rollup@4.41.1)
commondir: 1.0.1
estree-walker: 2.0.2
- fdir: 6.4.4(picomatch@4.0.2)
+ fdir: 6.4.4(picomatch@4.0.3)
is-reference: 1.2.1
magic-string: 0.30.17
- picomatch: 4.0.2
+ picomatch: 4.0.3
optionalDependencies:
rollup: 4.41.1
@@ -13641,7 +13637,7 @@ snapshots:
dependencies:
'@types/estree': 1.0.7
estree-walker: 2.0.2
- picomatch: 4.0.2
+ picomatch: 4.0.3
optionalDependencies:
rollup: 3.29.4
@@ -13649,7 +13645,7 @@ snapshots:
dependencies:
'@types/estree': 1.0.7
estree-walker: 2.0.2
- picomatch: 4.0.2
+ picomatch: 4.0.3
optionalDependencies:
rollup: 4.41.1
@@ -16769,9 +16765,9 @@ snapshots:
dependencies:
format: 0.2.2
- fdir@6.4.4(picomatch@4.0.2):
+ fdir@6.4.4(picomatch@4.0.3):
optionalDependencies:
- picomatch: 4.0.2
+ picomatch: 4.0.3
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
@@ -19107,8 +19103,6 @@ snapshots:
picomatch@2.3.1: {}
- picomatch@4.0.2: {}
-
picomatch@4.0.3: {}
pify@4.0.1: {}
@@ -20663,8 +20657,8 @@ snapshots:
tinyglobby@0.2.10:
dependencies:
- fdir: 6.4.4(picomatch@4.0.2)
- picomatch: 4.0.2
+ fdir: 6.4.4(picomatch@4.0.3)
+ picomatch: 4.0.3
tinyglobby@0.2.13:
dependencies:
@@ -21068,7 +21062,7 @@ snapshots:
unplugin@2.3.5:
dependencies:
acorn: 8.14.1
- picomatch: 4.0.2
+ picomatch: 4.0.3
webpack-virtual-modules: 0.6.2
unrs-resolver@1.11.1: