From a22fe27b756280e945d330b10a5bc459420f29b8 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Mon, 14 Sep 2026 00:30:18 +0200 Subject: [PATCH] chore: seed the lode and enable the pre-PR gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Durable project memory that an agent can read before touching the code, written from the code as it is rather than from the README. lode/ holds the baseline (summary, terminology, practices, the map, plans) plus one folder per subsystem: cli, translation, state-and-validators, config-and-providers, quality, rubocop-cops, testing-and-ci, docs-site. Every line range, constant count and example count in it was computed with a script, not read off the screen: the 13 CLI switches, the 8 legacy flags, the 20 static rules across 5 categories, the 14 British spellings, the 14/18 terminology terms with 4 flagged in each, the 12 builtin language names covering 11 languages, the 11 spec files and their 101 examples. Two CLI claims that could only be settled by running it — `lingo status` creating `.i18n-state/translation.log`, and a subcommand after an option being silently ignored — were run. lode/workflow.md is the profile the shared /lode:* workflow skills read for this repository: the commands, the branch and PR rules, the layer table, the input shapes a change must be checked against, the reviewer suggestions that are wrong here, the docs mapping, the CI facts and the per-file conflict rules. The eight local commands the plugin supersedes are retired into it: lfg, github-review-pr, github-review-failures, github-review-comments, plan and tdd existed and are deleted (finish-prs and debug-flaky never existed here), and CLAUDE.md's command table now names the /lode: equivalents. review-pr and security stay — the plugin does not cover them. lode/review/ holds six entries: five rules and one "Not a bug", from the five cubic learnings and the merged PR review threads on #6 and #8. The two cubic learnings about the /lfg branch-switch guard were already merged upstream into one; its subject file is retired, so the rule now lives in workflow.md -> Branches and PRs, where /lode:lfg reads it. One finding present in PR #8's threads but not in the cubic list — an issue or PR body with code fences goes through --body-file — was added from that source. Nothing was dropped: every rule still has a live subject in the tree. Doc-versus-code disagreements found while reading are not recorded here; a lode file states only what is true of the tree it ships in. They are in the PR body. Claude-Session: https://claude.ai/code/session_01Hga3idYBp7fWdj7GmmfofF --- .claude/commands/github-review-comments.md | 237 --------------- .claude/commands/github-review-failures.md | 174 ----------- .claude/commands/github-review-pr.md | 162 ----------- .claude/commands/lfg.md | 321 --------------------- .claude/commands/plan.md | 92 ------ .claude/commands/tdd.md | 145 ---------- .claude/settings.json | 13 + .gitignore | 1 + CLAUDE.md | 40 ++- lode/cli/summary.md | 144 +++++++++ lode/config-and-providers/summary.md | 128 ++++++++ lode/docs-site/summary.md | 86 ++++++ lode/lode-map.md | 57 ++++ lode/plans/README.md | 14 + lode/practices.md | 79 +++++ lode/quality/summary.md | 105 +++++++ lode/review/commands-and-docs.md | 108 +++++++ lode/rubocop-cops/summary.md | 78 +++++ lode/state-and-validators/summary.md | 98 +++++++ lode/summary.md | 46 +++ lode/terminology.md | 56 ++++ lode/testing-and-ci/summary.md | 107 +++++++ lode/translation/summary.md | 144 +++++++++ lode/workflow.md | 164 +++++++++++ 24 files changed, 1457 insertions(+), 1142 deletions(-) delete mode 100644 .claude/commands/github-review-comments.md delete mode 100644 .claude/commands/github-review-failures.md delete mode 100644 .claude/commands/github-review-pr.md delete mode 100644 .claude/commands/lfg.md delete mode 100644 .claude/commands/plan.md delete mode 100644 .claude/commands/tdd.md create mode 100644 .claude/settings.json create mode 100644 lode/cli/summary.md create mode 100644 lode/config-and-providers/summary.md create mode 100644 lode/docs-site/summary.md create mode 100644 lode/lode-map.md create mode 100644 lode/plans/README.md create mode 100644 lode/practices.md create mode 100644 lode/quality/summary.md create mode 100644 lode/review/commands-and-docs.md create mode 100644 lode/rubocop-cops/summary.md create mode 100644 lode/state-and-validators/summary.md create mode 100644 lode/summary.md create mode 100644 lode/terminology.md create mode 100644 lode/testing-and-ci/summary.md create mode 100644 lode/translation/summary.md create mode 100644 lode/workflow.md diff --git a/.claude/commands/github-review-comments.md b/.claude/commands/github-review-comments.md deleted file mode 100644 index f028a7d..0000000 --- a/.claude/commands/github-review-comments.md +++ /dev/null @@ -1,237 +0,0 @@ ---- -model: sonnet -description: "Use when a PR has unresolved review comments that need responses -- evaluates each comment, implements valid fixes, pushes back on incorrect suggestions, and resolves all threads." -argument-hint: "PR number (e.g., 123 or #123)" -allowed-tools: Bash(gh pr list:*), Bash(gh pr view:*), Bash(gh pr diff:*), Bash(gh pr comment:*), Bash(gh api:*), Bash(git log:*), Bash(git blame:*), Bash(git push:*), Bash(git commit:*), Bash(git add:*), Bash(bundle exec:*), Read, Write, Edit, Glob, Grep, Agent ---- - -# Review GitHub PR Comments: $ARGUMENTS - -You are reviewing and responding to all unresolved review comments on a GitHub pull request. Apply technical rigour -- evaluate each comment against the actual codebase before accepting or rejecting it. - -## Phase 0: Determine the PR Number - -The user may provide a PR number as `$ARGUMENTS`. Parse it flexibly: - -- `PR123`, `PR 123`, `pr123` -> PR 123 -- `123` -> PR 123 -- `#123` -> PR 123 -- Empty/blank -> auto-detect from current branch - -**If no PR number is provided**, detect it automatically: - -```bash -gh pr list --author=@me --head="$(git branch --show-current)" --state=open --json number,title -``` - -If exactly one open PR exists for the current branch, use it. If none or multiple, ask the user. - -Once you have the PR number, confirm it: - -```bash -gh pr view --json title,state,url -``` - ---- - -## Phase 1: Fetch All Unresolved Review Comments - -Retrieve all review comments and identify unresolved ones: - -```bash -# Get all review comments (not resolved) -gh api "repos/mhenrixon/locallingo/pulls//comments" --paginate - -# Get all review threads to check resolution status -gh api graphql -f query=' - query($owner: String!, $repo: String!, $pr: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $pr) { - reviewThreads(first: 100) { - nodes { - id - isResolved - path - line - comments(first: 20) { - nodes { - id - databaseId - body - author { login } - createdAt - } - } - } - } - } - } - } -' -f owner=mhenrixon -f repo=locallingo -F pr= -``` - -For each unresolved thread, extract: -- Thread ID (for resolving) -- Comment body (the review feedback) -- File path and line number (if inline) -- Author (to understand context) - -Filter to only **unresolved** threads. Skip bot comments (CodeRabbit, dependabot), resolved threads, and PR description comments. - -If there are no unresolved review comments, report that and stop. - ---- - -## Phase 2: Read and Categorise Each Comment - -For each unresolved comment, read the full body and categorise it: - -| Category | Action | -|----------|--------| -| Valid fix needed | Implement the fix | -| Valid test gap | Add the missing test | -| Valid style/consistency issue | Fix it | -| Incorrect suggestion | Push back with technical reasoning | -| Suggestion conflicts with architecture | Push back, reference existing patterns | -| Over-engineering / YAGNI | Push back, explain why it's unnecessary | -| Unclear | Ask for clarification (do NOT implement) | - -**Before categorising**, always: -1. Read the actual file and line being commented on -2. Check if the suggestion is technically correct for THIS codebase -3. Check if it would break existing functionality -4. Check if existing patterns/conventions contradict the suggestion -5. Check the repo's conventions (the README, existing code patterns, and `docs/AGENTS.md` for the docs app) -- project conventions override reviewer preferences. Remember the gem's floor is Ruby 3.2: reject suggestions that need 3.3+/3.4-only syntax. - ---- - -## Phase 3: Implement Accepted Fixes - -For all comments you've decided to accept: - -1. **Make the code changes** -- edit the relevant files -2. **Run affected tests** to verify nothing breaks: - ```bash - bundle exec rspec - ``` -3. **Run validators**: - ```bash - bundle exec rubocop - ``` -4. **Commit** all fixes together with a clear message: - ```bash - git commit -m "$(cat <<'EOF' - fix: address PR review feedback - - - Description of fix 1 - - Description of fix 2 - EOF - )" - ``` -5. **Push** to the remote branch: - ```bash - git push - ``` - ---- - -## Phase 4: Reply to Every Comment - -For **each** unresolved thread, reply: - -### For accepted fixes: - -Reply with what was fixed and the commit SHA: - -```bash -gh api "repos/mhenrixon/locallingo/pulls//comments//replies" \ - --method POST \ - -f 'body=Fixed in . .' -``` - -### For rejected suggestions: - -Reply with technical reasoning: - -```bash -gh api "repos/mhenrixon/locallingo/pulls//comments//replies" \ - --method POST \ - -f 'body=' -``` - -### Resolving threads (via GraphQL): - -After replying, resolve the thread: - -```bash -gh api graphql -f query=' - mutation($threadId: ID!) { - resolveReviewThread(input: {threadId: $threadId}) { - thread { isResolved } - } - } -' -f threadId= -``` - -### For general PR comments (not inline review threads): - -Reply directly: - -```bash -gh pr comment --body "" -``` - ---- - -## Phase 5: Verify Completion - -After processing all comments, verify no unresolved threads remain: - -```bash -gh api graphql -f query=' - query($owner: String!, $repo: String!, $pr: Int!) { - repository(owner: $owner, name: $repo) { - pullRequest(number: $pr) { - reviewThreads(first: 100) { - totalCount - nodes { isResolved } - } - } - } - } -' -f owner=mhenrixon -f repo=locallingo -F pr= -``` - -Report the final tally: how many comments were accepted/fixed, how many were pushed back on, and confirm all threads are resolved. - ---- - -## Response Style - -When replying to comments: - -- **No performative agreement** -- never say "Great point!" or "You're absolutely right!" -- **No gratitude** -- never say "Thanks for catching that" -- **Be direct** -- state the fix or the reasoning, nothing more -- **Reference commits** -- always include the short SHA when a fix was made -- **Be specific** -- when pushing back, reference actual code, not abstract principles - -When pushing back: - -- Use technical reasoning grounded in the actual codebase -- Reference existing patterns if the suggestion contradicts them -- Reference the repo's documented conventions when applicable -- Explain what would break or what edge case the reviewer missed -- If the suggestion is valid in principle but wrong for this context, say so - ---- - -## Important Notes - -- Always read the actual code before evaluating a comment -- reviewers sometimes misread diffs -- If a comment reveals a genuine bug you missed, fix it without defensiveness -- If multiple comments suggest the same change, implement it once and reference the fix in all replies -- Bot reviewers (CodeRabbit, etc.) sometimes suggest changes that conflict with project conventions -- verify against the repo's actual patterns before accepting -- If a new round of review comments appears after your push (from re-review), report that to the user rather than entering an infinite loop - -Now begin by determining the PR number from `$ARGUMENTS` or the current branch. diff --git a/.claude/commands/github-review-failures.md b/.claude/commands/github-review-failures.md deleted file mode 100644 index 20bde1f..0000000 --- a/.claude/commands/github-review-failures.md +++ /dev/null @@ -1,174 +0,0 @@ ---- -model: sonnet -description: "Use when CI checks are failing on a PR — fetches failure logs, diagnoses root causes, implements fixes, and pushes until CI is green." -argument-hint: "PR number (e.g., 41 or #41)" -allowed-tools: Bash(gh pr list:*), Bash(gh pr view:*), Bash(gh pr checks:*), Bash(gh pr diff:*), Bash(gh api:*), Bash(gh run view:*), Bash(git log:*), Bash(git diff:*), Bash(git push:*), Bash(git commit:*), Bash(git add:*), Bash(bundle exec:*), Read, Write, Edit, Glob, Grep, Agent ---- - -# Fix GitHub CI Failures: $ARGUMENTS - -You are diagnosing and fixing CI failures on a GitHub pull request. Work systematically: identify failures, read logs, diagnose root causes, fix locally, verify, push. - -## Phase 0: Determine the PR Number - -The user may provide a PR number as `$ARGUMENTS`. Parse it flexibly: - -- `PR41`, `PR 41`, `pr41` -> PR 41 -- `41` -> PR 41 -- `#41` -> PR 41 -- Empty/blank -> auto-detect from current branch - -**If no PR number is provided**, detect it automatically: - -```bash -gh pr list --author=@me --head="$(git branch --show-current)" --state=open --json number,title -``` - -If exactly one open PR exists for the current branch, use it. If none or multiple, ask the user. - -Once you have the PR number, confirm it: - -```bash -gh pr view --json title,state,url,mergeable -``` - -**Pre-flight: merge conflicts (detection only).** If `mergeable` is `CONFLICTING`, STOP — do not diagnose CI on a conflicted branch (the merge itself may fix or cause the failures). Report the conflict and hand off to `/github-review-pr`, whose Phase A0 owns the resolution runbook — this command's toolset deliberately does not include the merge machinery. If `mergeable` is `UNKNOWN`, note it and proceed: the orchestrator resolves the ambiguity; a standalone run shouldn't block on GitHub's recompute. - ---- - -## Phase 1: Identify Failing Checks - -```bash -gh pr checks -``` - -CI is a single job type — `bundle exec rake` (spec + rubocop, per the Rakefile) — run on a Ruby matrix: - -| Check Type | Examples | How to Get Logs | -|------------|----------|----------------| -| Suite + lint (rake = spec + rubocop) | `rake (Ruby 3.2)`, `rake (Ruby 3.3)`, `rake (Ruby 3.4)` | `gh run view --job= --log-failed` | - -Extract the run ID and job IDs from the check URLs. The URL format is: -`https://github.com/mhenrixon/locallingo/actions/runs//job/` - -If all checks pass or are pending, report that and stop. - ---- - -## Phase 2: Fetch Failure Logs - -For each failing check, get the logs: - -```bash -# Get the failed job logs (condensed output) -gh run view --job= --log-failed -``` - -If `--log-failed` output is too large or unclear, try: - -```bash -# Full log for a specific job -gh run view --job= --log 2>&1 | tail -100 -``` - ---- - -## Phase 3: Diagnose Each Failure - -For each failure, determine the root cause. A `rake (Ruby X.Y)` job fails on the FIRST of specs or rubocop that breaks — read the log to see which half failed. - -### Lint Failures - -Look for: -- RuboCop offenses: file path, line number, cop name, message - -**Key**: RuboCop failures can often be auto-fixed with `bundle exec rubocop -A `. The lint scope is `exe lib spec Rakefile Gemfile locallingo.gemspec` (Rakefile patterns); the `docs/` app has its own separate `.rubocop.yml` and is NOT covered by this job. - -### Spec Failures - -Look for: -- Test name and file path -- Error class and message -- Relevant backtrace lines (ignore framework noise) -- Whether it's a test environment issue vs actual code bug - -**Key patterns**: -- `NameError: uninitialized constant` -> missing require or renamed class -- `NoMethodError: undefined method` -> API change, missing method -- `Errno::ENOENT` in specs -> fixture/tmpdir path issue (the suite writes locale fixtures under `Dir.mktmpdir`) -- `expected: X, got: Y` -> logic bug or test needs updating - -### Build/Dependency Failures - -Look for: -- Bundle install failures in the `Set up Ruby` step (bundler-cache): dependency conflicts, a gem that dropped support for an older matrix Ruby - ---- - -## Phase 4: Fix Locally - -For each diagnosed failure: - -1. **Read the relevant file** to understand context before fixing -2. **Make the fix** -- edit the file -3. **Verify locally** before committing: - -```bash -# For rubocop failures -bundle exec rubocop - -# For spec failures -bundle exec rspec - -# For full validation (exactly what CI runs) -bundle exec rake -``` - -### Fix Priority Order - -1. **Lint/style fixes** first (fast, deterministic) -2. **Spec failures** second (may require understanding the code change) -3. **Build/dependency issues** third (usually Gemfile or gemspec) - ---- - -## Phase 5: Commit and Push - -```bash -git add -git commit -m "$(cat <<'EOF' -fix(ci): - -- Fix 1 description -- Fix 2 description -EOF -)" -git push -``` - ---- - -## Phase 6: Verify - -After pushing, check if CI has been re-triggered: - -```bash -gh pr checks -``` - -If there are still pending checks, report which checks are running and what was fixed. Do NOT poll in a loop -- report the status and let the user know. - -If you can identify that certain failures will persist for environmental reasons (e.g., a runner outage or a RubyGems network hiccup during bundle install), flag that explicitly. - ---- - -## Important Notes - -- **Read before fixing** -- always read the actual failing code before attempting a fix -- **Fix the root cause** -- don't add `# rubocop:disable` to bypass lint; fix the actual issue (a targeted `# rubocop:disable` is acceptable only when RuboCop is demonstrably wrong) -- **Don't fix unrelated failures** -- if a spec was already failing on main, note it but don't fix it in this PR -- **The Ruby matrix is 3.2 / 3.3 / 3.4** -- a failure on only ONE Ruby version is a version-specific bug, not flakiness. The gem's floor is Ruby 3.2 (`required_ruby_version`, `TargetRubyVersion: 3.2`), so a fix must not use 3.3+/3.4-only syntax; check the failing version's log for `SyntaxError` first. -- **Flaky tests** -- if a test passes locally but fails in CI, note it as potentially flaky rather than adding workarounds -- **Don't retry CI blindly** -- diagnose first, fix, then push. Each push triggers a full CI run across all three Rubies. - -Now begin by determining the PR number and fetching the failing checks. diff --git a/.claude/commands/github-review-pr.md b/.claude/commands/github-review-pr.md deleted file mode 100644 index 914c5f2..0000000 --- a/.claude/commands/github-review-pr.md +++ /dev/null @@ -1,162 +0,0 @@ ---- -model: opus -description: "Use when a PR needs full review — resolves merge conflicts with the base first, then fixes CI failures, then addresses unresolved review comments. Conflicts first so CI diagnoses the post-merge reality; failures before comments because comment fixes trigger new CI runs that obscure the original failures." -argument-hint: "PR number (e.g., 42 or #42)" -allowed-tools: Bash(gh pr list:*), Bash(gh pr view:*), Bash(gh pr checks:*), Bash(gh pr checkout:*), Bash(gh pr diff:*), Bash(gh pr comment:*), Bash(gh api:*), Bash(gh run view:*), Bash(git log:*), Bash(git blame:*), Bash(git diff:*), Bash(git status:*), Bash(git switch:*), Bash(git fetch:*), Bash(git merge:*), Bash(git merge-tree:*), Bash(git rev-parse:*), Bash(git push:*), Bash(git commit:*), Bash(git add:*), Bash(bundle exec:*), Bash(bun install:*), Read, Write, Edit, Glob, Grep, Agent ---- - -# Review GitHub PR (full pass): $ARGUMENTS - -You are running a full review pass on a pull request. The pass has three phases that MUST run in this order: - -1. **Phase A0: merge conflicts** — bring the branch up to date with its base and resolve any conflicts before anything else. -2. **Phase A: CI failures** — fix anything red before touching review comments. -3. **Phase B: review comments** — only after Phase A leaves CI green (or pending green after a push). - -## Why this order matters - -**Conflicts before failures**: CI results only matter for the code that will actually merge. On a conflicted (or stale) branch you'd diagnose failures against a base that no longer exists — and the conflict resolution itself changes code, invalidating the run you just fixed. Resolving conflicts first means Phase A reads CI for the post-merge reality, and you spend exactly one extra CI cycle instead of two. - -**Failures before comments**: if you fix review comments first, every commit pushes a new CI run. By the time the review-comment fixes finish, the original failure logs are buried under new pipeline runs. Symptoms: - -- The `rake (Ruby 3.2)` log you needed to read is now from a stale run; the latest run is still in progress on top of your unrelated comment fixes. -- A review-comment fix accidentally repairs the CI failure as a side effect, and you lose the chance to verify the failure was real. -- A review-comment fix accidentally INTRODUCES a CI failure, and you can't tell whether the new failure was pre-existing or your fault. - -Conflicts-first, then failures-first eliminates this confusion. CI is either green or red on a known commit against the current base; the review-comment fixes layer cleanly on top. - -## Phase 0: Determine the PR Number - -The user may provide a PR number as `$ARGUMENTS`. Parse it flexibly: - -- `PR42`, `PR 42`, `pr42` → PR 42 -- `42` → PR 42 -- `#42` → PR 42 -- Empty/blank → auto-detect from current branch - -**If no PR number is provided**, detect it automatically: - -```bash -gh pr list --author=@me --head="$(git branch --show-current)" --state=open --json number,title -``` - -If exactly one open PR exists for the current branch, use it. If none or multiple, ask the user. - -Once you have the PR number, confirm it: - -```bash -gh pr view --json title,state,url -``` - ---- - -## Phase A0: Merge conflicts - -Check whether the branch merges cleanly into its base: - -```bash -gh pr view --json mergeable,mergeStateStatus,baseRefName -``` - -| `mergeable` | Action | -|-------------|--------| -| `MERGEABLE` | Skip to Phase A. | -| `UNKNOWN` | GitHub is recomputing (common right after pushes, and it can stay UNKNOWN for minutes). Don't poll it — verify **locally**, against the PR's actual head (NOT `HEAD`, which may be some other checked-out branch): `git fetch origin ` and `git fetch origin pull//head`, verify both refs resolve (`git rev-parse --verify origin/^{commit}` and `git rev-parse --verify FETCH_HEAD^{commit}` — a bad ref also exits 1 from merge-tree, so exit code alone can't be trusted), then `git merge-tree --write-tree --name-only origin/ FETCH_HEAD`. Clean exit → no conflicts, skip to Phase A. Exit 1 **with conflict output** → resolve below (the `--name-only` file list is your work list). | -| `CONFLICTING` | Resolve, below. | - -### Resolution procedure - -1. Check out the PR's branch (`gh pr checkout `) with a clean tree (`git status`). Stash nothing — if the tree is dirty, stop and ask the user. -2. `git fetch origin ` then **`git merge origin/`** — MERGE, never rebase. The branch is shared (it has a PR); a rebase would require a force-push to a shared branch, which is never acceptable. -3. Resolve every conflicted file **semantically** — read both sides and produce the version that preserves BOTH changes' intent. Never blanket `--ours`/`--theirs` a source file. Repo-specific rules: - - **`CHANGELOG.md` (Unreleased)**: union — keep BOTH sides' entries (main's landed bullets and this branch's), most recent first, without duplicating the `### Added`/`### Fixed`/`### Changed` subheads (Keep a Changelog format). Losing either side is a real regression reviewers rarely catch. - - **`lib/locallingo/version.rb`**: releases land DIRECTLY on `main` via `rake release[X.Y.Z]` (the Rakefile bumps, commits, pushes `main`, and creates the GitHub Release — no PR), so an ordinary feature branch never edits this file — a conflict here means the BRANCH bumped it on purpose (a release-prep PR). Keep the branch's bump in that case; if the intent isn't obvious from the branch's own commits, stop and ask. Only take the base's version when the branch's edit was clearly accidental. - - **`docs/bun.lock`** (the only tracked lockfile — the gem root's `Gemfile.lock` is gitignored and can never conflict, and `docs/Gemfile.lock` isn't committed): take the base's file, then run `bun install --cwd docs` so the branch's own dependency changes, if any (in `docs/package.json`), re-resolve on top. Never hand-edit a lockfile. - - **Shipped default config** (`config/default.yml`, `config/locallingo.default.yml`): both sides usually added/changed different keys — merge to keep both, and confirm the result is valid YAML the gem can load. - - This repo tracks **no generated artifacts** (the docs app's compiled Tailwind CSS under `docs/app/assets/builds/` is gitignored) — every other conflicted file is source; merge it semantically. -4. Run the verification gates BEFORE pushing the merge — scoped to what the conflict touched, at minimum: - ```bash - bundle exec rubocop - bundle exec rspec - # or exactly what CI runs (spec + rubocop in one): - bundle exec rake - ``` - The `docs/` app is NOT exercised by PR CI (its deploy workflow fires on release), so a `docs/` conflict has no CI safety net — review the semantic merge extra carefully there. -5. Commit the merge (keep git's standard merge-commit message; add a body line naming any non-obvious resolution choice) and `git push` — a merge commit never needs force. - -### Phase A0 exit criteria - -- The PR reports `MERGEABLE` (or the local `git merge-tree` check is clean), AND the merge commit (if one was needed) is pushed. -- If the merge produced changes, CI is now re-running — that's expected; Phase A reads the fresh run. -- If a conflict cannot be resolved with confidence (both sides rewrote the same logic and the correct combination isn't decidable from the code), **stop and ask the user** — a guessed resolution that compiles is worse than a question. - ---- - -## Phase A: Run `/github-review-failures` - -Invoke the existing `/github-review-failures` slash command with the same `$ARGUMENTS` value. Its purpose: fix every failing CI check, push, leave the branch in a state where CI is either green or running-pending-toward-green. - -Follow that command's full process — phases 1–6 of the failures runbook. The slash command is at `.claude/commands/github-review-failures.md`. Its workflow: - -1. Identify failing checks via `gh pr checks `. -2. Fetch failure logs. -3. Diagnose root cause for each. -4. Fix locally — lint first (fast, deterministic), then specs, then build issues. -5. Verify locally before commit (`bundle exec rspec `, `bundle exec rubocop`). -6. Commit + push + report which checks are now running. - -### Phase A exit criteria - -Before moving to Phase B, one of these must be true: - -- All CI checks are green on the latest pushed commit. OR -- All CI checks are pending (running) on the latest pushed commit, AND no checks failed in the most recent completed run on this commit. OR -- A persistent CI failure exists that is **not caused by changes on this branch** (e.g., a flaky test on `main`, or an environmental failure in the runner). Report this explicitly and proceed to Phase B with the caveat noted. - -If failures persist on this branch's changes, **do NOT proceed to Phase B**. Report what's still failing, what's been tried, and ask the user how to proceed. - ---- - -## Phase B: Run `/github-review-comments` - -Once Phase A's exit criteria are met, invoke `/github-review-comments` with the same `$ARGUMENTS`. Its purpose: address every unresolved review thread on the PR, push fixes, reply with commit SHAs, and resolve the threads. - -The slash command is at `.claude/commands/github-review-comments.md`. Its workflow: - -1. Fetch all unresolved review threads via the GitHub GraphQL API. -2. Read and categorise each comment (valid fix / invalid suggestion / unclear). -3. Implement accepted fixes; verify locally (specs, rubocop). -4. Commit all fixes together with a clear message; push. -5. Reply to every thread with the commit SHA (for accepted fixes) or technical reasoning (for rejections). -6. Resolve each thread via the GraphQL `resolveReviewThread` mutation. -7. Verify no unresolved threads remain. - -### Phase B exit criteria - -- All unresolved review threads have been replied to and resolved (or the user has explicitly approved leaving a specific thread open). -- The branch has been pushed with all accepted fixes. - ---- - -## Phase C: Final report - -Before reporting, re-check mergeability once more (`gh pr view --json mergeable`, or the local `git merge-tree` check if UNKNOWN) — the base can move underneath a long pass. If a NEW conflict appeared, loop back to Phase A0. - -After all phases complete, report: - -1. **Phase A0 summary**: whether the branch was conflicted, which files conflicted, how each was resolved (and the merge commit SHA) — or "clean merge, no action". -2. **Phase A summary**: which CI failures were diagnosed and fixed. Note the commit SHAs for the fixes. -3. **Phase B summary**: which review comments were accepted (with commit SHAs), which were pushed back on (with reasoning), and the final unresolved-thread count (should be 0). -4. **End state**: final mergeability + CI status on the latest commit. -5. **Outstanding work**: anything that still needs attention — e.g., CI was pending at the end of Phase B and the user should verify the latest run after the comment fixes. - ---- - -## Important Notes - -- **Do not interleave the phases.** Don't fix a CI failure, then a review comment, then another CI failure. The whole point of this command is the strict ordering. -- **A new CI failure emerging during Phase B** (e.g., a comment fix breaks a spec) means looping back to Phase A — fix the new failure before continuing comment work. Likewise, **a new conflict appearing mid-pass** (the base moved) means looping back to Phase A0. These loop-backs are the only allowed reverse directions. -- **If the PR is already merged**, there is nothing to review — report that and stop. (A stale `$ARGUMENTS` or a just-merged PR shows up as `state: MERGED` in Phase 0's confirm step.) -- **If the PR merges cleanly, has no failures AND no unresolved comments**, report "PR is clean" and stop. -- **If `$ARGUMENTS` is the same as the current open PR**, the two child slash commands will see the same PR. They share state through the git branch and the GitHub API, not through any in-process variable. -- **Don't re-implement the child slash commands' logic**. Invoke them and let them do their work. This command is the orchestrator. diff --git a/.claude/commands/lfg.md b/.claude/commands/lfg.md deleted file mode 100644 index a002729..0000000 --- a/.claude/commands/lfg.md +++ /dev/null @@ -1,321 +0,0 @@ ---- -description: "Executes full autonomous engineering workflow with verification. Use when implementing complete features, tackling GitHub issues, or running end-to-end development cycles." -model: opus -argument-hint: "GitHub issue number/URL or feature description" -allowed-tools: Bash(gh issue view:*), Bash(gh search:*), Bash(gh issue list:*), Bash(gh pr create:*), Bash(gh pr view:*), Bash(bundle exec:*), Bash(git:*), Read, Write, Edit, Glob, Grep, Agent ---- - -# LFG - Full Autonomous Workflow - -Execute a complete engineering workflow with verification at each phase. - -## Phase 0: Branch Setup - -**BEFORE any other work, prepare the git branch:** - -1. Check the current branch: `git branch --show-current` -2. Guard against losing work before switching: - - `git status --porcelain` — if non-empty, STOP and ask the user what to do with the uncommitted changes. Never stash silently. - - If the current branch is not `main`, check for committed-but-unpushed work: if the branch has an upstream (`git rev-parse --verify --quiet @{upstream}` succeeds), run `git log --oneline @{upstream}..`; otherwise the branch was never pushed, so run `git log --oneline main..` (every commit on it is unpushed). If either lists commits, STOP and tell the user which branch would be left behind. -3. If NOT on `main`, switch: `git checkout main` -4. Pull latest: `git pull origin main` -5. Create feature branch: `git checkout -b issue-{number}-{brief-description}` (or `feature/{description}` if no issue number) - ---- - -## Phase 1: Understand - -### Step 1: Gather Requirements - -If `$ARGUMENTS` is a GitHub issue number or URL: - -```bash -gh issue view --json title,body,labels,assignees,comments -``` - -If `$ARGUMENTS` is a description, use it directly. - -### Step 2: Define Acceptance Criteria - -**MANDATORY:** Write explicit acceptance criteria: - -- **GIVEN** [context/setup] -- **WHEN** [action taken] -- **THEN** [expected outcome] - -You MUST NOT proceed until you can articulate these clearly. - -### Step 3: Comprehension Gate - -Before proceeding, you must: - -1. State the problem/feature in one sentence -2. Explain WHY this is needed (business context) -3. List what will change from the user's perspective -4. Identify edge cases not explicitly mentioned -5. Explain the data flow or code path involved - -If you cannot complete ALL five items, investigate further. - -### Step 4: Create Task List - -Create a TaskCreate todo list with specific implementation steps. - ---- - -## Phase 2: Explore - -1. Find related files (Glob/Grep or Explore agent) -2. Read existing patterns in similar features -3. Understand dependencies and integration points -4. Check existing test coverage -5. Review the orchestration flow in `lib/locallingo/manager.rb` -6. Check drift-state handling in `lib/locallingo/state_store.rb` -7. Review validators in `lib/locallingo/validators/` and quality rules in `lib/locallingo/quality/` -8. Check configuration resolution in `lib/locallingo/configuration.rb` / `settings.rb` - ---- - -## Phase 3: Plan - -1. List files to modify with specific changes -2. List new files to create with purpose -3. Identify state-format or config-schema changes (and their upgrade story) -4. Plan test coverage (TDD: tests FIRST) -5. Update task list with implementation steps -6. Consider backwards compatibility with existing `.locallingo.yml` configs and `.i18n-state/` files - ---- - -## Phase 4: Implement (TDD) - -### The deviation log (keep it from the first edit) - -The plan is the map; the codebase is the territory. The moment reality forces a choice the plan or issue didn't settle, log it in `implementation-notes.md` at the repo root — one line, at the moment it happens, not reconstructed later: - -- **Deviations** — the plan said X, you did Y, because Z -- **Discoveries** — facts about the codebase the plan didn't know -- **Judgment calls** — choices the user might have made differently (defaults, naming, scope cuts) - -Pick the conservative option and keep going. The log is how the user audits your judgment afterwards. Never commit the file: its contents move into the PR body (Phase 7), then the file is deleted. - -For each logical unit: - -### 4.1: Write Failing Test First - -Create a test that demonstrates the expected behavior. Run it to confirm it FAILS: - -```bash -bundle exec rspec -``` - -### 4.2: Implement Minimum Code - -Write the MINIMUM code to make the test pass. Follow project patterns: - -| Never Do | Always Do | -|----------|-----------| -| Bump `version.rb` in a PR | Leave versioning to `rake release[x.y.z]` | -| Silently reset corrupted state | Raise `Locallingo::Error` with an actionable message | -| Drop `manual` flags on sync | Preserve hand-edit protection through every rewrite | -| Rewrite unchanged state files | Skip byte-identical writes (diff hygiene) | -| Store or log API keys | Resolve lazily from ENV/configure blocks | -| Hardcode app-specific behavior | Drive it from `.locallingo.yml` | -| Real LLM calls in specs | Mock the provider | - -### 4.3: Refactor - -Once green, refactor while keeping tests passing. - -### 4.4: Validate - -```bash -bundle exec rake rubocop -``` - -### 4.5: Repeat - -Move to next logical unit. Mark task items complete. - ---- - -## Phase 5: Deep Root Cause Analysis (Bug Fixes Only) - -**If this is a bug fix, apply deep investigation before implementing:** - -### Trace the Data Lifecycle - -For the key/state entry causing the issue: -- Where was the state entry written? By which Manager path (translate, sync, accept-edits)? -- What source/target hash was recorded? What does the locale file actually contain? -- What ASSUMPTIONS does the code make at the failure point? -- Which assumption was violated, and WHY? - -### Use Git History - -```bash -git log --oneline -20 -git blame -``` - -- When was the code written? What was the original intent? -- Has something ELSE changed that invalidated the original assumptions? - -### Map All Callers - -Don't just look at the method that failed: -- Use Grep to find all call sites -- Different contexts (CLI command vs Manager internal vs validator)? -- Does the error only happen in ONE context? Why? - -### Five Whys - -Keep asking WHY until you reach a meaningful fix point: - -1. Error: X happened -> Why? -2. Because Y -> Why was Y in that state? -3. Because Z -> Why wasn't Z prevented? -4. Because no check existed -> Why not? -5. **THIS** is where the fix belongs - -### Fix Location Principle - -The best fix is usually NOT where the error is raised: -- Nil entry in a validator -> fix in the sync path that should have written it -- Key churn in diffs -> fix in the writer, not by post-processing files -- Drift misdetection -> fix the hash source, not the comparison - -**Ask: "Where is the EARLIEST point I could prevent this error?" Fix there.** - -### Unacceptable Superficial Fixes -- DO NOT DO THESE - -- `rescue nil` without understanding why the exception occurs -- `&.` to silence nil errors without investigating why nil occurs -- `if object.present?` guards without understanding why missing -- `return if entry.nil?` to silently skip processing -- Wrapping everything in `begin/rescue` to swallow errors - -**These HIDE bugs. The root cause continues causing issues elsewhere.** - ---- - -## Phase 6: Verify - -**ALL of these must pass before committing:** - -```bash -bundle exec rake rubocop # Style -bundle exec rspec # Tests -``` - -### Solution Verification - -Re-read the original requirements and verify: -- "If I were the requester, would I consider this fully resolved?" -- "Have I addressed the ROOT CAUSE, not just the symptom?" -- "Do my tests prove the issue is ACTUALLY fixed, not just suppressed?" -- "Does this maintain backwards compatibility?" - ---- - -## Phase 7: Commit & PR - -### Commit - -```bash -git add -git commit -m "$(cat <<'EOF' -feat(scope): brief description - -## Summary -[What changed and why] - -## Test Coverage -- spec 1: validates requirement X -- spec 2: validates edge case Y - -## Verification -- [x] bundle exec rake rubocop passes -- [x] bundle exec rspec passes -EOF -)" -``` - -### Push & PR - -```bash -git push -u origin $(git branch --show-current) - -gh pr create --title "feat(scope): brief description" --body "$(cat <<'EOF' -## Summary -- Key change 1 touching `lib/foo.rb` -- Key change 2 - -Closes # - -## Test plan -- [ ] Scenario 1 -- [ ] Scenario 2 -EOF -)" -``` - -**Markdown inside the quoted heredoc is literal — do not escape.** The single-quoted `<<'EOF'` delimiter disables shell expansion on the body, so: - -- Write backticks as backticks: `` `foo` ``. Do NOT write `\`foo\``; that writes a literal backslash-backtick and breaks the code span. -- Write dollar signs as-is: `$HOME`. No escaping needed. -- Write backslashes as-is: `\n` stays `\n`. - -The body is copied verbatim into the PR / commit message. If you would not type a backslash in a GitHub comment, do not type one in the heredoc. - -If the body is long or contains many backticks / tables, prefer writing it to a temp file and passing `--body-file`: - -```bash -cat > /tmp/pr-body.md << 'EOF' -## Summary -...any markdown... -EOF -gh pr create --title "..." --body-file /tmp/pr-body.md -rm /tmp/pr-body.md -``` - -The `--body-file` path avoids the double-layer of shell interpretation entirely and makes long PR bodies easier to read in the terminal buffer. - -The PR body MUST end with a `## Deviations & judgment calls` section copied from -`implementation-notes.md` (then delete the file). If the plan held completely, -write "None — the plan held." This section is read FIRST in review — it is the -audit trail for every decision the plan didn't make. - ---- - -## Phase 8: Comprehension Close-Out - -The tests prove the CODE is right; this phase keeps the USER's mental model right. After the PR is up, end your final message with: - -1. **The decisions, not the diff** — the 3–5 non-obvious choices in this change someone must understand to maintain it. Lead with anything from the deviation log; the user has never seen those. -2. **Three merge-gate questions** the user should be able to answer before merging. If any answer isn't obvious to them, offer a walkthrough — an unanswerable question is comprehension debt, and merging anyway is how it compounds. - ---- - -## Verification Checklist - -- [ ] All acceptance criteria met -- [ ] Tests written BEFORE implementation -- [ ] `bundle exec rake rubocop` passes -- [ ] `bundle exec rspec` passes -- [ ] Backwards compatibility maintained (config, state files, CLI flags) -- [ ] No version bump included -- [ ] PR created with description -- [ ] PR body ends with `## Deviations & judgment calls` (from implementation-notes.md, since deleted) -- [ ] Comprehension close-out delivered (decisions + three merge-gate questions) - ---- - -## Handoff - -When complete: -- All phases executed -- Verification passed -- PR created and linked - -Now, execute this workflow for the provided issue or feature. diff --git a/.claude/commands/plan.md b/.claude/commands/plan.md deleted file mode 100644 index 1eea2ef..0000000 --- a/.claude/commands/plan.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -description: "Investigates the codebase, designs a solution, and produces a durable plan artifact — a GitHub issue or a plan markdown under docs/plans/. Read-only: never edits application code. Use before /lfg for anything non-trivial." -model: fable -argument-hint: "issue | md | " -allowed-tools: Bash(gh issue create:*), Bash(gh issue list:*), Bash(gh issue view:*), Bash(gh search:*), Bash(gh label list:*), Bash(git log:*), Bash(git diff:*), Bash(git branch:*), Bash(date:*), Read, Grep, Glob, Write, Agent, AskUserQuestion ---- - -# Plan — design expensive, execute cheap - -You are the planning specialist. This command runs on the most capable model deliberately: the thinking happens here, the execution happens later on cheaper models (`/lfg` on Opus, implementation on Sonnet). That split only works if the plan is **self-contained** — an executor with none of this session's context must be able to implement it without guessing. - -## Output mode from $ARGUMENTS - -| $ARGUMENTS starts with | Artifact | -|------------------------|----------| -| `issue` | GitHub issue (default — feeds directly into `/lfg `) | -| `md` or `file` | Markdown file at `docs/plans/YYYY-MM-DD-.md` (date from `date +%F`) | -| anything else | GitHub issue | - -## Hard constraints - -- **Read-only for source code.** Never edit application code, never commit, never create branches. The only file you may Write is a new plan markdown under `docs/plans/`. -- **Never reproduce secrets** (keys, tokens, credentials) in the plan, even redacted ones you encounter while reading config. -- **Dedupe before creating an issue**: `gh issue list --search ""` — if an existing issue covers this, extend it in your summary instead of duplicating. - -## Phase 1 — Investigate - -Protect this session's context: delegate mechanical exploration to cheaper subagents and keep Fable for judgment. - -1. Fan out Explore agents (`model: haiku`) for file discovery and naming-convention sweeps; use `model: sonnet` agents when a subsystem needs to be read and summarized. Launch independent explorations in parallel. -2. Read the load-bearing files yourself — the ones the design decision actually hinges on. Don't design from subagent summaries alone. -3. Check the architecture layers in `CLAUDE.md` and read the matching source files — past decisions and gotchas live there. -4. Check `git log` for recent related work; the design should extend it, not fight it. - -## Phase 2 — Surface the unknowns (blindspot pass + interview) - -Investigation tells you what the codebase says; this phase finds what the REQUEST doesn't say. Run it BEFORE designing — a wrong assumption caught here costs one question; caught in review it costs a rewrite. - -1. **Blindspot pass.** Write down the unknowns you are carrying into the design: - - decisions the request leaves open (defaults, naming, public API/config surface, rollout & upgrade story) - - edge cases the codebase makes possible that the request never mentions - - anything with no precedent in this repo — flag it explicitly as unknown-unknown territory -2. **Interview the user** with AskUserQuestion, one question at a time, prioritized by blast radius: architecture-changing answers first, then public API / config surface, then UX. Rules: - - Skip anything the codebase, CLAUDE.md, or an existing issue already answers. - - 2–5 questions is the sweet spot; zero is fine when the request is genuinely unambiguous — say so rather than inventing questions. - - Every question offers concrete options with a recommended default, never an open-ended essay prompt. -3. **Record the answers** in the plan's Decision section as `Settled in interview:` bullets — constraints the executor must not re-litigate. - -## Phase 3 — Design - -- Develop 2-3 candidate approaches with real tradeoffs. Pick one and say why; record why the others lost. -- The chosen design must respect project invariants: non-destructive state sync (`manual` flags survive every rewrite), idempotent newline-terminated state writes, corrupted state raises rather than resets, no version bumps in PRs, app-specific behavior lives in `.locallingo.yml`, API keys never stored or logged, backwards compatibility for existing configs / state files / CLI flags, TDD (specs named before implementation steps), mocked providers in specs. -- Decide the test strategy per the testing rules: unit specs for config/state/validators, `with_app`-fixture specs for Manager workflows, mocked provider for translation paths. - -## Phase 4 — Emit the plan artifact - -Use this structure for the issue body or markdown file. Every section is load-bearing — an executor uses Context to avoid re-discovery, Steps to act, Gates to verify, Boundaries to stop. - -```markdown -# - -## Problem / Goal -<What's wrong or missing, who it affects, what done looks like.> - -## Context (read these first) -<Bullet list: `path/to/file.rb` — why it matters to this change. Include CLI, manager, state, validator, and quality layers as relevant. Self-contained: no references to "as discussed" or this session.> - -## Decision -<Chosen approach and rationale. Then: alternatives considered and why each was rejected. End with `Settled in interview:` bullets for every constraint the user confirmed in the interview phase — the executor must not re-litigate these.> - -## Implementation steps -<Ordered, small, each mapped to the appropriate architecture layer. Specs come before the code they cover. Name exact files to create or change.> - -## Verification gates -<Exact commands + expected outcome:> -- `bundle exec rspec <paths>` — all green -- `bundle exec rake rubocop` — no offenses - -## Out of scope -<Explicit boundaries — the adjacent things an eager executor must NOT do.> - -## Execution -Execute with `/lfg <issue-number>` (or `/lfg docs/plans/<file>.md`). -``` - -For GitHub issues: create with `gh issue create --title "..." --body-file <tmpfile>`. Write the body to a temp file first; do not use inline heredoc with `gh issue create --body` (code fences get mangled by shell interpolation). - -For markdown files: Write to `docs/plans/YYYY-MM-DD-<slug>.md`. Leave it uncommitted — committing is the user's call. - -## Phase 5 — Handoff - -Report back: link to the issue (or file path), the chosen approach in 2-3 sentences, and the exact execute command. Stop there — do not start implementing. diff --git a/.claude/commands/tdd.md b/.claude/commands/tdd.md deleted file mode 100644 index 7427fd0..0000000 --- a/.claude/commands/tdd.md +++ /dev/null @@ -1,145 +0,0 @@ ---- -description: "Use when implementing any feature or fixing any bug -- enforces RED-GREEN-REFACTOR: write failing test first, implement minimum code to pass, then refactor." -model: sonnet ---- - -# TDD Command - -Enforce test-driven development methodology with RED -> GREEN -> REFACTOR cycle. - -## The TDD Cycle - -```text -RED -> GREEN -> REFACTOR -> REPEAT - -RED: Write a failing test (test MUST fail first) -GREEN: Write MINIMAL code to pass (nothing more) -REFACTOR: Improve code while keeping tests green -REPEAT: Next feature/scenario -``` - -## When to Use - -- Implementing new features -- Adding new validators or quality rules -- Fixing bugs (write test that reproduces bug FIRST) -- Refactoring existing code -- Modifying Manager workflows (translate, sync, accept-edits) -- Changing StateStore behavior -- Adding CLI commands or flags - -## Workflow - -### Step 1: Write Failing Tests (RED) - -```ruby -# spec/locallingo/example_spec.rb -RSpec.describe Locallingo::NewFeature do - describe "#check" do - context "when the key is missing in the target locale" do - it "reports it" do - expect(subject.check(source, target)).to include("g.missing") - end - end - - context "when the target was hand-edited" do - it "leaves the manual flag intact" do - expect(state.dig("g.edited", "manual")).to be(true) - end - end - end -end -``` - -### Step 2: Run Tests - Verify FAIL - -```bash -bundle exec rspec spec/locallingo/example_spec.rb - -FAIL - NotImplementedError / Expected behavior not met -``` - -**Tests MUST fail before implementing.** This confirms: -- Tests are actually running -- Tests are testing the right thing -- Implementation doesn't already exist - -### Step 3: Implement Minimal Code (GREEN) - -Write the minimum code to make the test pass. - -### Step 4: Run Tests - Verify PASS - -```bash -bundle exec rspec spec/locallingo/example_spec.rb - -N examples, 0 failures -``` - -### Step 5: Refactor (IMPROVE) - -Improve code while keeping tests green: -- Extract methods to reduce complexity -- Improve naming -- Reduce duplication - -### Step 6: Run Full Suite - -```bash -bundle exec rspec -``` - -## Coverage Requirements - -| Code Type | Minimum Coverage | -|-----------|------------------| -| All code | 80% | -| StateStore | 100% | -| Manager sync / accept-edits paths | 100% | -| Validators | 100% | -| Configuration / Settings | 100% | - -## Test Types to Include - -### Unit Tests (Configuration, StateStore, KeyFlattener) -- Happy path scenarios -- Edge cases (empty locales, missing namespaces, corrupted state) -- Error conditions (state corruption raises `Locallingo::Error`) - -### Workflow Tests (Manager) -- Use the `with_app` / `write_state` / `read_state` fixtures in `spec/support/locale_fixtures.rb` -- Translate / sync / accept-edits round-trips -- Manual-flag preservation across rewrites -- Byte-identical files left untouched (no diff churn) - -### Provider Tests -- Mock the RubyLLM provider — specs must NEVER make network calls -- Assert on prompt/payload shape, not provider internals - -## Best Practices - -**DO:** -- Write the test FIRST, before any implementation -- Run tests and verify they FAIL before implementing -- Write MINIMAL code to make tests pass -- Refactor only after tests are green -- Use `Dir.mktmpdir` / `with_app` for filesystem-touching specs -- Test state-format compatibility explicitly when changing the writer - -**DON'T:** -- Write implementation before tests -- Skip running tests after each change -- Write too much code at once -- Ignore failing tests -- Test implementation details (test behavior) -- Skip testing error paths - -## Checklist - -- [ ] Tests written BEFORE implementation -- [ ] Tests fail initially (RED phase verified) -- [ ] Minimal code written to pass (GREEN) -- [ ] Code refactored with tests still passing -- [ ] Coverage meets requirements (80%+) -- [ ] All edge cases covered -- [ ] Backwards compatibility maintained diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..90b8653 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,13 @@ +{ + "extraKnownMarketplaces": { + "zoolutions": { + "source": { + "source": "github", + "repo": "zoolutions/claude-plugins" + } + } + }, + "enabledPlugins": { + "lode@zoolutions": true + } +} diff --git a/.gitignore b/.gitignore index 2cc04a6..f15428f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ *.gem Gemfile.lock .rspec_status +/lode/tmp/ diff --git a/CLAUDE.md b/CLAUDE.md index ad02e10..1b50aaf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,6 +3,14 @@ AI-assisted i18n translation, drift detection, and quality linting on top of i18n-tasks — packaged as a gem (ships the `lingo` CLI). +## Memory + +Durable project memory lives in `lode/` (index: `lode/lode-map.md`). Read it +before exploring the code. `lode/review/` holds accepted review findings as +rules about the system; `/lode:gate` enforces them before any push, and +`/lode:learn` adds to them. `lode/workflow.md` is the repo profile the shared +`/lode:*` workflow skills read. + ## Tech Stack - **Ruby**: >= 3.2 (CI matrix: 3.2, 3.3, 3.4) @@ -40,18 +48,25 @@ rake release[x.y.z] # Release (version bump + tag + push) — maintainer | Command | Purpose | |---------|---------| -| `/lfg` | Full autonomous workflow: branch → understand → explore → plan → TDD → verify → PR | -| `/plan` | Fable-powered planning → GitHub issue or `docs/plans/` markdown (read-only; execute with `/lfg`) | -| `/github-review-comments` | Process unresolved PR review comments | -| `/github-review-failures` | Diagnose and fix failing CI checks on a PR | -| `/github-review-pr` | Full PR review: conflicts → CI failures → review comments | -| `/review-pr` | Review a PR for pattern compliance | -| `/tdd` | Enforce RED → GREEN → REFACTOR cycle | +| `/lode:lfg` | Full autonomous workflow: branch → understand → explore → plan → TDD → verify → gate → PR | +| `/lode:plan` | Read-only planning → GitHub issue (execute with `/lode:lfg`) | +| `/lode:tdd` | Enforce RED → GREEN → REFACTOR | +| `/lode:review-pr` | Full PR pass: conflicts → CI failures → review comments | +| `/lode:finish-prs` | Drive a stack of open PRs to merge-ready, one at a time | +| `/lode:debug-flaky` | Root-cause an intermittent test — evidence → repro → fix | +| `/lode:gate` | Pre-PR gate: fresh-context review against the rules and `lode/review/`, looped until clean | +| `/lode:learn` | Write accepted review findings into `lode/review/` | +| `/lode:sync` | Keep `lode/` true to the code after a change | +| `/review-pr` | Quick local review of a PR for pattern compliance | | `/security` | Security audit (API keys, shell hooks, state/file handling) | -Commands pin a model tier via frontmatter aliases: `sonnet` for pattern-following -implementation, `opus` for orchestration and full PR review, `fable` for -read-only planning (`/plan`). Use aliases, not full model IDs. +The `/lode:*` commands come from the `lode@zoolutions` plugin +(`.claude/settings.json`); they read `lode/workflow.md` for this repository's +commands, branch rules, input shapes, CI facts and conflict rules. + +The two local commands pin a model tier via frontmatter aliases — `opus` for +both, since review and security audits are judgment work. Use aliases, not full +model IDs. ## Architecture @@ -84,9 +99,12 @@ RuboCop lib/rubocop/cop/locallingo/ (RelativeI18nKey, StrftimeInView), conf ## More Documentation +See `lode/lode-map.md` first — the index of this repository's durable memory. + See `.claude/` directory: -- `commands/` — Slash command definitions +- `commands/` — the two local slash commands (`review-pr`, `security`) - `rules/` — Coding style, git workflow, testing, agents +- `settings.json` — enables the `lode@zoolutions` plugin See `docs/` for the published documentation site (own bundle — excluded from the gem's rubocop task; don't run root `bundle exec rubocop` against it). diff --git a/lode/cli/summary.md b/lode/cli/summary.md new file mode 100644 index 0000000..6dd9464 --- /dev/null +++ b/lode/cli/summary.md @@ -0,0 +1,144 @@ +# CLI: `cli.rb` and `reporter.rb` + +`exe/lingo` is six lines: `require "locallingo"` then +`Locallingo::CLI.start(ARGV)`. Everything below is `lib/locallingo/cli.rb` +(235 lines) and `lib/locallingo/reporter.rb` (147 lines). + +## 1. The order of `run` + +`CLI#run` (`cli.rb:57-66`) does five things, in this order, and the order is +load-bearing: + +1. `resolve_command` (`cli.rb:79-94`) — consumes the leading token. +2. `parse_options!` (`cli.rb:96-100`) — `OptionParser#parse!` over the rest. +3. `load_setup_file` (`cli.rb:72-75`) — `load`s `.locallingo.rb` from `Dir.pwd` + if it is a file. Absent is fine; an error inside it propagates. +4. `Locallingo.configuration(root_path: Dir.pwd, package: options[:package])`. +5. `dispatch` (`cli.rb:133-149`). + +So `--package` must be parsed before the config resolves, and the setup file +runs before any provider is constructed. The whole body is wrapped in +`rescue Locallingo::Error => e`, which prints `❌ <message>` to stderr and +`exit 1` — `MissingCredentialsError` included, since it subclasses `Error`. +Nothing else is rescued: a malformed locale file raises `Psych::SyntaxError` out +of `load_locale_translations` and the process dies with a backtrace (verified). +An unparseable *LLM reply* does not reach here — `Manager#translate_batch` and +`QualityChecker#suggest_improvements` each rescue `StandardError` themselves. + +## 2. Command resolution, and the gotcha + +`resolve_command` has exactly three paths: + +| First token | Result | +|---|---| +| nil | `"status"` | +| a key of `LEGACY_FLAGS` (8 entries) | shift it, warn `[deprecated] \`<flag>\` — use \`lingo <sub>\` instead.`, return the subcommand | +| anything not starting with `-` | shift it and use it as the subcommand | +| anything else starting with `-` | `"status"`, token left for `OptionParser` | + +The last row is the gotcha: **a subcommand after an option is ignored.** +`lingo --locale de translate` resolves to `status`, `OptionParser` strips +`--locale de`, and `translate` is left in `@argv` where nothing reads it — the +command runs `status` and exits 0. Verified by running it. A legacy flag is only +recognised in first position for the same reason. + +An unknown subcommand reaches `dispatch`'s `else`, which warns +`Unknown command: <cmd>` + `Run \`lingo --help\`.` and exits 1. + +## 3. Options + +`build_parser` (`cli.rb:102-131`) defines 13 switches. `@options` starts as +`{ format: :text }`. + +| Switch | Effect | +|---|---| +| `-l`, `--locale LOCALE` | `:locale` — one target locale | +| `-f`, `--force` | `:force` — re-translate everything except manual keys | +| `--force-key KEY` | appends to `:force_keys` | +| `--key KEY` | appends to `:keys` (accept-edits) | +| `--all` | `:all` (accept-edits) | +| `-v`, `--verbose` | `:verbose` — `Manager#log` also writes to stderr | +| `-n`, `--dry-run` | `:dry_run` | +| `--strict` | `:strict` | +| `--strict-all` | `:strict` **and** `:strict_all` | +| `--ai` | `:use_ai` | +| `--json` | `:format = :json` | +| `--package PATH` | `:package` | +| `-h`, `--help` | prints the banner and `exit 0` | + +`--strict-all` implies `--strict`; the reverse is not true, and +`Reporter#exit_code` picks the `:strict_all` tier whenever `strict_all` is set. + +## 4. What each command does and prints + +`dispatch` builds one `Reporter` and routes to a `cmd_*` method. Every command +constructs its own `Manager` or `QualityChecker` (`cli.rb:227-233`) — no shared +state. + +| Command | Method | Prints (verbatim) | Writes | +|---|---|---|---| +| `status` | `cmd_status` `151-153` | `Reporter#status` | `.i18n-state/` + `translation.log` (see §6) | +| `translate` | `cmd_translate` `155-165` | `🔄 Translating...` (verbose only), `📝 Running post-translate hooks...`, `✅ Translation complete!`, `(dry run - no changes made)` | locale YAML + state, unless `--dry-run` | +| `validate` | `cmd_validate` `167-170` | `Reporter#violations`, then `exit(<its return value>)` | nothing beyond §6 | +| `quality` | `cmd_quality` `172-176` | `Reporter#quality` | nothing | +| `fix-quality` | `cmd_fix_quality` `178-185` | `🔧 Fixing quality issues for <locale>...` or `🔍 Checking fixes for <locale>...`, `Fixed: <n> files`, `Skipped: <n> non-fixable suggestions` | locale YAML, unless `--dry-run` | +| `accept-edits` | `cmd_accept_edits` `187-198` | `manual_edits validator is disabled…` (and returns), or `report_accepted`'s lines | state, unless `--dry-run` | +| `hash` | `cmd_hash` `211-214` | the 8-hex fingerprint, or `{"hash":"…"}` under `--json` | nothing | +| `sync` | `cmd_sync` `216-225` | `🔄 Syncing state file…` or `🔍 Would sync state file…`, `State directory: …`, per-locale key counts, `Total tracked keys: <n>` | state, unless `--dry-run` | + +Two notes the table cannot carry: + +- **`translate --dry-run` still calls the model.** `Manager#translate_locale` + runs the full batch loop and only skips `merge_translations` / + `update_locale_state` / `@state.save`; `cmd_translate` additionally skips the + `after_translate` hooks. It costs tokens and wall time and prints no plan. +- **`sync --dry-run` prints the state that is already on disk.** `sync_state!` + builds its return value with `@state.load` *after* skipping the saves, so the + counts are the pre-existing ones, not the projected ones. + +`report_accepted` (`cli.rb:200-209`) prints +`Nothing to accept — no hand-edited translations found.` when the total is zero, +else one ` <locale>: <n> key(s)` line per locale with any accepts and +`✅ Marked <n> translation(s) as intentional.` + +## 5. Exit codes, in full + +- `0` — the default; also `-h/--help`. +- `1` — `validate --strict`/`--strict-all` when a violation of a tier type is + present (`Reporter#exit_code`, `reporter.rb:66-72`); an unknown subcommand; + any `Locallingo::Error` (corrupted state, an unknown `--package`, an unknown + terminology setting, `accept-edits --key` naming a key no locale has, missing + provider credentials). +- Anything else — an unrescued exception's own exit status. + +`validate` without a strict flag prints violations and exits 0 by design. + +## 6. Side effects even the read-only commands have + +Every command that builds a `Manager` creates `state_dir` twice over — +`StateStore#initialize` `mkdir_p`s it (`state_store.rb:18-21`) and +`Manager#build_logger` (`manager.rb:410-417`) `mkdir_p`s it again and opens +`<state_dir>/translation.log` as a rotating `Logger` (5 files, 1 MiB each). +So `lingo status` on a fresh checkout creates `.i18n-state/translation.log`. +Verified by running it. `QualityChecker` takes a `logger:` and never builds one, +so `quality` and `fix-quality` write no log. + +## 7. `Reporter` + +`Reporter` (`reporter.rb`) owns all text and JSON rendering and the strict exit +code; it writes to an injectable `io` (default `$stdout`) and never exits itself. +`--json` short-circuits each of `#status`, `#violations`, `#quality` to +`JSON.pretty_generate` of the raw structure — so the JSON shape *is* the Ruby +shape, symbol keys and all. + +Text mode truncates: violations print the first 10 per type with +` ... and <n> more`; quality prints the first 20 per severity, ordered +`error, warning, info` (`print_quality_by_severity`, `reporter.rb:110-122`), each +with its text truncated to 60 characters. `Manager#status` has already truncated +`missing_keys`/`outdated_keys` to 10 before the reporter sees them. + +## See also + +- `../translation/summary.md` — what `Manager` does with these options +- `../quality/summary.md` — `quality` / `fix-quality` +- `../config-and-providers/summary.md` — `--package`, `.locallingo.rb` diff --git a/lode/config-and-providers/summary.md b/lode/config-and-providers/summary.md new file mode 100644 index 0000000..6e8889d --- /dev/null +++ b/lode/config-and-providers/summary.md @@ -0,0 +1,128 @@ +# Configuration, settings and the LLM provider + +Three files that decide *what* the engine does and *who* it talks to, plus the +parser that survives the answer. + +## 1. `Configuration` (`lib/locallingo/configuration.rb`, 161 lines) + +Loads `.locallingo.yml` and exposes typed readers, so no other class parses a +raw config hash. + +**Resolution** (`resolve`, `113-117`) is three deep merges, in this order: + +1. `shipped_defaults` — the `defaults:` block of + `config/locallingo.default.yml`, found via `DEFAULT_CONFIG_PATH` + (`File.expand_path("../../config/locallingo.default.yml", __dir__)`). +2. `user_defaults` — the `defaults:` block of the app's config file. +3. `package_overrides` — only when `package:` was passed: the `packages:` entry + whose `"path"` equals it, minus `"path"`. A `package:` naming no entry raises + `Locallingo::Error, "No package <path> in <file>"`. + +`config_file` (`141-145`) takes the first of `CONFIG_FILENAMES` +(`.locallingo.yml`, `.locallingo.yaml`) that exists at `root_path`. With no file +at all, both user steps return `{}` and the shipped defaults stand alone, so +`Locallingo.configuration` never fails for a missing config. + +`load_yaml` (`147-149`) is `YAML.safe_load(ERB.new(File.read(path)).result, +aliases: true)`. Two consequences: the config is **ERB-evaluated**, so +`<%= ENV["…"] %>` works (and arbitrary Ruby in a config file runs), and it is +`safe_load`, so no arbitrary object deserialization. + +`deep_merge` (`151-159`) recurses only when both sides are Hashes; an Array value +(like `target_locales`) is replaced wholesale by an override, never concatenated. + +**Paths** all hang off `base_path` (`39-41`): `root_path`, or +`root_path/<package>` when scoped. So `locales_dir`, `state_dir`, +`exceptions_dir` (`state_dir/exceptions`) and `log_file` +(`state_dir/translation.log`) all move with `--package`. + +**Readers that `fetch` without a default will raise `KeyError` if the shipped +defaults ever stop supplying them**: `source_locale`, `target_locales`, +`locales_dir`, `state_dir`, `provider`. Those with a default — +`context` (`"a business application"`), `placeholder_style` +(`"%<name>s, %<count>s"`), `glossary` (`{}`), `after_translate` (`[]`) — tolerate +a hand-written config file that omits them only because the shipped defaults are +merged in first. + +`language_guide(locale)` (`70-77`) returns `""` when unset, the value verbatim +when it is a String, and `File.read` of `guide["file"]` expanded against +`base_path` when it is a Hash with a `"file"` key — returning `""` if that path +does not exist, silently. + +`language_name(locale)` (`81-83`) is `BUILTIN_LANGUAGE_NAMES[locale.to_s] || +locale.to_s`. The map has 12 entries covering 11 distinct languages (`nb` and +`no` both map to `"Norwegian"`), and an unknown locale falls back to its own +code, which then appears verbatim in the prompt. + +## 2. `Settings` (`lib/locallingo/settings.rb`, 31 lines) + +The credentials half, deliberately separate from the YAML half: keys must never +live in `.locallingo.yml`. `PROVIDERS` is five symbols — `openai`, `anthropic`, +`gemini`, `deepseek`, `openrouter` — and `attr_accessor` generates one +`<provider>_api_key` accessor per entry. + +`api_key_for(provider)` (`22-29`) returns nil for a provider outside that list, +calls the value if it responds to `call` (**every time — never memoised**, so a +rotating or lazily-available key works), strips it, and maps blank to nil. + +`Locallingo.settings` memoises one instance; `Locallingo.reset_settings!` clears +it and is what the spec suite calls in an `after` hook. + +## 3. `Providers::RubyLLM` (`lib/locallingo/providers/ruby_llm.rb`, 127 lines) + +The only place the gem touches a model SDK. `CREDENTIAL_ENV` maps the same five +provider symbols to their ENV var names. + +**Credential precedence is one chain, stated once** in `resolved_api_key` +(`95-97`): `settings_api_key` (`Locallingo.configure`) → `host_configured_api_key` +(what the app already set through `RubyLLM.configure`, inspected only if +`RubyLLM` is already defined — the class never requires it just to peek) → +`env_api_key`. + +- `credentials?` (`38-42`) returns **true for any provider not in + `CREDENTIAL_ENV`** — an unknown provider is assumed configured elsewhere + rather than blocked. +- `ensure_credentials!` (`45-53`) raises `MissingCredentialsError` naming the ENV + var, the `Locallingo.configure` call and the `.locallingo.rb` file. + `Manager#translate!` calls it first; `QualityChecker#suggest_improvements` + instead checks `credentials?` and warns-and-skips, so a missing key fails + `translate` and merely degrades `quality --ai`. +- `chat` (`57-69`) `require`s `ruby_llm` lazily, calls `configure_credentials!`, + builds a **fresh** `RubyLLM.chat(model:, provider:, assume_model_exists: true)` + per call so batches share no history, sends the payload as + `JSON.pretty_generate`, and parses the reply through `JsonExtraction`. +- `configure_credentials!` (`79-91`) pushes the resolved key into + `RubyLLM.config` because RubyLLM does not read provider keys from ENV itself. + It is `respond_to?`-guarded on both the reader and the writer, and it returns + early without overwriting a key the host already set unless + `Locallingo.configure` supplied one. + +No key is ever written to disk, interpolated into a log line, or included in an +error message — `ensure_credentials!` names the *variable*, not a value. + +## 4. `JsonExtraction` (`lib/locallingo/json_extraction.rb`, 99 lines) + +Recovers the JSON object from a model reply, because not every provider has a +fenceless-JSON mode. `extract_object` (`24-38`) tries four strategies in order: + +1. `JSON.parse` of the whole (stripped) string. +2. A fenced block — ` ```json … ``` ` — via `fenced_block` (`49-51`). +3. `first_balanced_object` (`56-68`): every `{` in the text, in turn, as a + candidate start. +4. `JSON.parse(text)` again, unrescued, so the raised error carries a useful + message. + +A top-level value that parses but is not a Hash (an array, say) raises +`JSON::ParserError, "Expected a top-level JSON object, got <class>"` — both +callers treat the result as key→value. + +`balanced_object` (`73-97`) is the careful part: it tracks brace depth while +honouring string literals and backslash escapes, so a `}` inside a translated +string does not close the object. The reason the naive greedy `/\{.*\}/m` is +rejected is written above the module: translation prompts are *about* preserving +`%{placeholder}`s, so a model echoing one in prose is expected, not exotic. + +## See also + +- `../translation/summary.md` — the prompt these credentials serve +- `../quality/summary.md` — the other caller of `chat` diff --git a/lode/docs-site/summary.md b/lode/docs-site/summary.md new file mode 100644 index 0000000..bcb1ca2 --- /dev/null +++ b/lode/docs-site/summary.md @@ -0,0 +1,86 @@ +# Docs site (`docs/`) + +A self-contained Rails 8.1 app built on [docs-kit](https://github.com/zoolutions/docs-kit), +with its own `Gemfile`, `.rubocop.yml`, `bun.lock` and Dockerfile. It is not part +of the gem (the gemspec ships only `exe/`, `lib/`, `config/`, `CHANGELOG.md`, +`LICENSE.txt`, `README.md`) and it has no PR CI. + +## 1. How a page exists + +Every page is a `DocsUI::Page` subclass under `docs/app/views/docs/pages/` **and** +a `page "Title", group: …` line in `docs/app/models/doc.rb`. Without the registry +line the page is not routed and not in the nav; the generator +(`bin/rails g docs_kit:page "Title" --group=…`) writes both. A registered page +whose view class does not resolve is silently skipped everywhere, so the registry +doubles as a burn-down list. + +The full authoring contract is `docs/AGENTS.md` (and, live, `/docs/authoring`). +Its load-bearing rules: prose is `md <<~'MD'` inside a `DocsUI::Section`, never a +Markdown `##` for page structure; reference material uses +`DocsUI::PropTable`/`FieldTable`/`Callout`/`Code`; the page must render with +JavaScript off; never hand-write HTML or daisyUI markup. + +## 2. The 16 pages and what each documents + +| Page (slug) | Documents | +|---|---| +| Overview (`overview`) | what the gem is, how the parts fit | +| Installation (`installation`) | Gemfile, binstub, credentials, config file | +| Quick start (`quick-start`) | status → translate → validate → sync | +| CLI reference (`cli`) | command shape, shared options, legacy aliases, exit codes | +| Commands (`commands`) | each subcommand in `CLI::COMMANDS` | +| Configuration (`configuration`) | `.locallingo.yml`, merge order, ERB | +| Providers & models (`providers`) | `provider:`, the two models, credentials | +| Prompt & glossary (`prompt-glossary`) | `context`, `glossary`, `placeholder_style`, language guides | +| Multiple packages (`packages`) | the `packages:` list and `--package` | +| Configuration reference (`configuration-reference`) | every config key with its default | +| Validators (`validators`) | the four validators and their violation types | +| Drift & state (`drift-state`) | hashes, `.i18n-state/`, manual protection, `sync` | +| Quality linting (`quality`) | static rules, terminology, British spellings, AI pass, fixing | +| RuboCop cops (`rubocop-cops`) | the two cops and their config | +| Continuous integration (`ci`) | wiring `validate --strict` into a pipeline | +| Migrating from a script (`migrating`) | `bin/translate` → `lingo`, flag→subcommand | + +Which page a behaviour change must update follows that table: a new CLI option +touches `cli` *and* `commands`; a new config key touches `configuration` *and* +`configuration-reference`; a change to what `sync` preserves touches +`drift-state` *and* `commands`. A fact that appears on two pages has to change on +both. + +## 3. Tooling + +- `cd docs && bin/dev` — Procfile-driven local server. +- `cd docs && bin/rubocop` — omakase + docs-kit cops, `TargetRubyVersion: 3.4`, + with an explicit `AllCops/Include` so the nested app lints itself. +- `cd docs && bin/ci` — the ActiveSupport::ContinuousIntegration script in + `docs/config/ci.rb`: setup, `bin/rubocop`, `bin/bundler-audit`, + `bin/importmap audit`, `bin/brakeman`. **No test step — the app has no + `spec/` directory.** +- `cd docs && bun run build:css` → `bin/build-css`, which resolves the `daisyui` + and `docs-kit` gem paths with `bundle show`, writes them into + `app/assets/stylesheets/tailwind.sources.css`, and runs the Tailwind CLI. It + aborts if a gem path cannot be resolved, because a silently-missing `@source` + ships an unstyled site. + +`docs/Gemfile` takes the gem with `gem "locallingo", path: "..", require: +"locallingo/version"` — only the version constant loads, so the gem's own +runtime deps never boot inside the docs app. `docs/Gemfile.lock` is **not** +committed (the root `.gitignore`'s bare `Gemfile.lock` matches it), so unlike +sibling repos there is no post-release lockfile pin to refresh. + +`app/assets/stylesheets/tailwind.sources.css` is a committed generated file +holding absolute gem paths; see `../review/commands-and-docs.md` for why that is +deliberate. `app/assets/builds/*` is gitignored. + +## 4. Deploy + +`docs/config/deploy.yml` is a dash 4 config (`minimum_version: 4.0.7`) targeting +ghcr.io image `zoolutions/locallingo`, service `locallingo`, behind +dash-proxy with compression, a 300s shared cache, security headers and +`intercept_errors` for 502/503/504. It ships on every published GitHub release +via `deploy-docs.yml`, so the docs go live with the gem. + +## See also + +- `docs/AGENTS.md` — the authoring contract in full +- `../testing-and-ci/summary.md` — why no docs job runs on a PR diff --git a/lode/lode-map.md b/lode/lode-map.md new file mode 100644 index 0000000..657010b --- /dev/null +++ b/lode/lode-map.md @@ -0,0 +1,57 @@ +# Lode map + +The index of this repository's durable memory. Read this first; it beats a +directory listing. Every file describes the system as it is now, with the +reasoning; `../CHANGELOG.md` records what changed. + +- `summary.md` — what Locallingo is, the offline/LLM split, the three invariants +- `terminology.md` — flat key, namespace, drift state, source_hash, target_hash, + the manual flag, violation vs suggestion, strict tier, package, exceptions +- `practices.md` — practices `../.claude/rules/` does not state: the three + questions every state writer answers, the duplicated loader, what `--dry-run` + means here, the failure directions already chosen +- `workflow.md` — the profile the shared `/lode:*` workflow skills read: + commands, branch and PR rules, layers, input shapes, wrong-here suggestions, + docs mapping, CI, flake sources, conflict rules, verification +- `plans/README.md` — plans are GitHub issues; `docs/plans/` does not exist yet + +## Subsystems + +- `cli/summary.md` — `exe/lingo`, `CLI` and `Reporter`: the order of `run`, the + three command-resolution paths (and why a subcommand after an option is + ignored), all 13 options, what each command prints and writes, every exit + code, and the state dir even read-only commands create +- `translation/summary.md` — `Manager` and `KeyFlattener`: loading and merging + locale YAML, the policy that decides what gets translated, the three-layer + batch retry, and which writer may touch which state field +- `state-and-validators/summary.md` — `StateStore`'s save rules and corruption + behaviour, the four validators and their inputs, and the protection invariant + end to end +- `config-and-providers/summary.md` — `Configuration`'s three-way merge and + typed readers, `Settings`, `Providers::RubyLLM`'s credential chain, and + `JsonExtraction`'s four strategies +- `quality/summary.md` — `QualityChecker`'s five check sources, the rule and + terminology constants with their counts, the random AI sample, and the blast + radius of `fix!` +- `rubocop-cops/summary.md` — the two shipped cops, why `rubocop` is a + development dependency, and the flag-but-don't-guess autocorrect rule +- `testing-and-ci/summary.md` — every spec file and what it covers, the two + support fixtures, the workflows, and `rake release` +- `docs-site/summary.md` — the docs-kit app: the registry rule, the 16 pages + mapped to the behaviour each documents, the tooling, the deploy + +## Review rules (`review/`) + +Accepted review findings rewritten as rules about the system and verified +against the code. `/lode:gate` reads every file here before reviewing a diff; +`/lode:learn` adds to them. + +- `review/commands-and-docs.md` — five rules and one *Not a bug*: the + branch-switch guard's single home, the `bundler-audit` install note, the Ruby + floor matching the gemspec, `gh` plus `allowed-tools` in the PR-review command, + `--body-file` for any fenced issue or PR body, and the generated + `tailwind.sources.css` (rejected on purpose) + +## Not memory + +- `tmp/` — git-ignored: gate diffs and reports, handovers, scratch diff --git a/lode/plans/README.md b/lode/plans/README.md new file mode 100644 index 0000000..84d9e6b --- /dev/null +++ b/lode/plans/README.md @@ -0,0 +1,14 @@ +# Plans + +There is no `docs/plans/` directory in this repository, and no plan file has +ever been committed. Plans live in **GitHub issues** on `zoolutions/locallingo`: +`/lode:plan` creates one with `gh issue create --title "…" --body-file <tmpfile>` +(a temp file, not an inline heredoc — code fences get mangled by shell +interpolation), and `/lode:lfg <issue-number>` executes it. + +When a plan genuinely needs to be a file — too long for an issue, or drafted +before the work is scheduled — write it to `docs/plans/YYYY-MM-DD-<slug>.md`, +creating the directory, and leave it uncommitted unless the user asks for it. + +Session handovers, gate reports and other scratch go in `../tmp/`, which is +git-ignored. diff --git a/lode/practices.md b/lode/practices.md new file mode 100644 index 0000000..9408664 --- /dev/null +++ b/lode/practices.md @@ -0,0 +1,79 @@ +# Practices + +The binding rules are `../CLAUDE.md` and `../.claude/rules/` +([coding-style](../.claude/rules/coding-style.md), +[git-workflow](../.claude/rules/git-workflow.md), +[testing](../.claude/rules/testing.md), +[agents](../.claude/rules/agents.md)). This file adds only what they do not +state. Specific accepted review findings live in `review/`. + +## State is the thing that cannot be rebuilt + +A locale file can be re-translated; `.i18n-state/` cannot be reconstructed from +anything but the files themselves plus history. Every change to a state writer +answers three questions before it is written: + +- Does an existing `manual: true` survive it? (Nothing clears the flag.) +- Does an existing `target_hash` survive it unrecomputed? (`||=`, never `=`.) +- Is the write skipped when the bytes are identical, and newline-terminated? + +`StateStore#save` is where the last one lives; `Manager#update_locale_state` and +`#sync_locale_state` are where the first two live. A new writer joins that list +and belongs in `state-and-validators/summary.md`'s table of writers. + +## Two loaders, one behaviour + +`Manager#load_locale_translations` and +`QualityChecker#load_locale_translations` are byte-identical (verified with +`diff`). Changing the glob patterns, the `content[locale]` guard or the +String-only filter in one and not the other silently gives `quality` a different +view of the files than `translate`. Fix both, or extract. + +## The flat key is the contract + +Everything downstream of `KeyFlattener.flatten` assumes dotted keys with bracket +indices, and the *first dotted segment* is the namespace that decides both the +state file and the locale file a translation lands in. A change to the flatten +or parse algorithm is a change to file layout, not a refactor. + +## Deciding what an operation does under `--dry-run` + +`--dry-run` in this codebase means "skip the writes", not "skip the work". A +`translate --dry-run` still calls the model and still costs tokens; a +`sync --dry-run` returns the state already on disk rather than the projected +state. When adding a flag or a command, say which of the two it is, in the +command's own doc page, rather than letting the reader assume "preview". + +## Failure directions already chosen + +Worth knowing before changing an error path, because each was decided on +purpose: + +- Corrupted state **raises**; it is never reset. +- An unreadable `exceptions/<locale>.yml` is **swallowed** into `{}` with a warn + log — the opt-out file must not stop a run. +- An exhausted translation batch **returns `{}`** and the keys are reported as + failed; the command still exits 0. +- A failing `after_translate` hook is **not detected at all** (`system`'s return + value is discarded). +- The AI quality pass **warns and returns `[]`** on any error, including missing + credentials; `translate` instead raises `MissingCredentialsError`. + +## Prose + +- Every user-facing fact lives on the CLI reference page *and* the page that + teaches the behaviour (`docs-site/summary.md` has the page-to-behaviour map). + Grep the docs for the subject before finishing a change. +- A list in the docs that mirrors a constant (providers, static-rule categories, + legacy flags, validators) is a completeness claim — open the constant and + count before editing the list. +- Transcripts and suggestion strings in docs are the exact strings the code + prints, including the emoji. + +## Review + +- A review-bot finding is evaluated against the code, not accepted by default; + a rejection is recorded as a `Not a bug:` entry in `review/` so the next + reviewer does not raise it again. +- Every accepted finding becomes a rule in `review/` in the same PR + (`/lode:learn`). diff --git a/lode/quality/summary.md b/lode/quality/summary.md new file mode 100644 index 0000000..f86ca63 --- /dev/null +++ b/lode/quality/summary.md @@ -0,0 +1,105 @@ +# Quality linting + +`Locallingo::QualityChecker` (`lib/locallingo/quality_checker.rb`, 207 lines) +plus the three modules under `lib/locallingo/quality/`. Unlike the translation +side, nothing here touches the drift state: quality reads locale files and +writes locale files. + +## 1. What a check produces + +`check(locale:, use_ai:)` (`35-42`) loads the locale (defaulting to +`config.source_locale`), runs `check_text` over every key, and appends an AI +pass when asked. `check_text` (`96-104`) runs five sources in this fixed order: + +1. `Quality::StaticRules.universal_fixes` — auto-fixable +2. `Quality::StaticRules.check` — the regex rules +3. `@terminology.check` — the configured term list +4. `Quality::BritishSpellings.check` — auto-fixable, **only when + `quality.british_spellings` is true and the locale being checked equals + `config.source_locale`** (`british_spellings_for?`, `106-108`) +5. a length heuristic — one `category: :length, severity: :info` suggestion when + `text.length > LONG_TEXT_THRESHOLD` (200) + +That fifth one is easy to forget: any list of "the static rules" that names only +the four modules is incomplete. + +A suggestion is a Hash with `key:, text:, locale:, category:, issue:, +severity:, source:`; `match:` on everything but the length heuristic; and `fix: +{from:, to:}` on the auto-fixable ones only. `source:` is `:static` or `:ai`. + +## 2. `StaticRules` (`quality/static_rules.rb`, 97 lines) + +`RULES` is 5 categories holding 20 regexes in total — terminology 4, +placeholders 2, clarity 6, business 5, accessibility 3. Severity is assigned by +category, not per rule (`severity_for_category`, `88-94`): `:placeholders` is +`:error`, `:terminology` and `:accessibility` are `:warning`, everything else +(including `:length` and `:grammar`) is `:info`. + +`UNIVERSAL_FIXES` is 2 entries — `"can not" => "cannot"` and +`"Can not" => "Cannot"` — matched with a case-**sensitive** `text.include?`, and +emitted with `category: :grammar, severity: :warning`. Because the terminology +rule `/\bcan not\b/i` also fires on the same text, one `can not` produces two +suggestions, one of them fixable. That is visible in the CLI output and is not a +bug. + +## 3. `BritishSpellings` (`quality/british_spellings.rb`, 45 lines) + +14 American→British pairs, each matched `/\b<american>\b/i` and emitted as a +`:warning` with a `fix:`. Source-locale only, opt-in. + +## 4. `Terminology` (`quality/terminology.rb`, 78 lines) + +A term maps to a suggestion String, or to `nil` meaning "reviewed and +acceptable" — `check` (`49-63`) skips the nil ones, so a list's size is not its +flag count. `BUSINESS` holds 14 terms of which 4 are flagged; `BANKING` is +`BUSINESS` plus 4 more always-acceptable regulatory terms, 18 terms and the same +4 flagged. `BUILTINS` names three settings: `business`, `banking`, `none`. + +`resolve` (`67-75`): nil setting → `BUSINESS`; a builtin name → that list; any +other value is treated as a path expanded against `base_path` and loaded with +`YAML.safe_load_file`, and a path that does not exist raises +`Locallingo::Error, "Unknown terminology <x> (not a builtin or a file)"`. +Matching is `text.downcase.include?(term.downcase)` — substring, not word +boundary, so `"transaction"` would match inside `"transactional"` if it were a +flagged term. + +## 5. The AI pass + +`suggest_improvements` (`72-92`) warns `⚠️ No LLM credentials — skipping AI +suggestions` and returns `[]` when `credentials?` is false, and rescues +`StandardError` into `⚠️ AI suggestion failed: <message>` + `[]` — the AI pass +can never fail the command. Each returned entry is symbolised and defaults +`severity` to `:info`. + +`ai_sample` (`119-123`) takes `translations.to_a.sample([size, 100].min).to_h` — +**a random sample capped at 100 keys**, so two `quality --ai` runs on the same +files return different AI suggestions, and a locale with more than 100 keys is +never fully reviewed. + +## 6. `fix!` and its blast radius + +`fix!` (`57-69`) re-runs `check(locale:)` *without* AI, keeps the suggestions +carrying `:fix`, and returns `{ fixed: <changed file count>, skipped: +<non-fixable suggestion count> }`. Note the asymmetry the CLI prints verbatim: +`fixed` counts **files**, `skipped` counts **suggestions**. + +`apply_fixes` (`125-141`) iterates every `**/*.<locale>.yml` under +`locales_dir` and, for each fixable suggestion whose `:text` appears anywhere in +the file's raw text, replaces **every occurrence of that text** with the +case-preserving correction (`content.gsub(suggestion[:text], fixed_text)`). +Two properties follow, and both matter: + +- The rewrite is done on the file's raw bytes, not on the parsed YAML, so + comments and formatting survive — and so does the risk that a value appearing + under two keys is fixed in both, including one that was never flagged. +- `apply_case_preserving_fix` (`143-150`) matches `/\b<from>\b/i` and picks + upcase / capitalize / as-written from the match, so `CAN NOT` becomes + `CANNOT`. + +Files are written only when their content actually changed, and not at all under +`--dry-run`; the "Would fix"/"Fixed" log line is emitted either way. + +## See also + +- `../cli/summary.md` — `quality` and `fix-quality` output +- `../config-and-providers/summary.md` — `quality.model`, credentials diff --git a/lode/review/commands-and-docs.md b/lode/review/commands-and-docs.md new file mode 100644 index 0000000..a9ceed1 --- /dev/null +++ b/lode/review/commands-and-docs.md @@ -0,0 +1,108 @@ +# Review rules: agent config and docs + +Accepted review findings, rewritten as rules about the repository as it is and +re-verified against the current tree. Every finding this repository has +accepted so far landed on its agent configuration (`CLAUDE.md`, `.claude/`) or +its docs app, not on `lib/` — the gem's own code has never drawn a review +comment. + +### The branch-switch guard is stated once, in the workflow profile + +- **Rule:** before switching away from the current branch, the procedure stops + for work that would be left behind — a non-empty `git status --porcelain`, or + committed-but-unpushed commits — and never stashes silently; and it checks + that an upstream exists before comparing against it, falling back to `main..` + when the branch was never pushed. The rule's single home is + `../workflow.md` → **Branches and PRs**; the local `/lfg` command that used to + carry it has been retired in favour of `/lode:lfg`, which reads the profile. +- **Holds because:** `git checkout main` succeeds with a dirty tree and with + unpushed commits, so a workflow that switches unconditionally silently drops + the work out of the new PR. And `git log @{upstream}..` hard-fails with + `fatal: no upstream configured` on a branch that was never pushed, which is + exactly the case where every commit is unpushed — so the existence check has + to come first or the guard cannot run at all. +- **Safe direction:** stopping to ask is free; a silently abandoned branch is + discovered later, by someone else. +- **Where:** `../workflow.md` → Branches and PRs +- **Proven by:** no test — it is a procedure, not code +- **Origin:** cubic learning 109ba01d (merged from two PR #8 threads, + `c98ec322` and `e7813ae6`) + +### `.claude/commands/security.md` names `bundler-audit` as an external tool with an install step + +- **Rule:** the Security Tools section says, in the comment above the command, + that `bundler-audit` is not a project dependency and must be installed first + (`gem install bundler-audit`) before `bundle audit check --update`. +- **Holds because:** `bundler-audit` appears in neither `locallingo.gemspec` nor + the root `Gemfile` — the only Gemfile that carries it is `docs/Gemfile`, for + the docs app's own `bin/bundler-audit`. An audit step documented as if it were + bundled errors out on a fresh checkout, and a security workflow that errors + out gets skipped. +- **Where:** `../../.claude/commands/security.md` (Security Tools) +- **Proven by:** no test — grep `Gemfile` and `locallingo.gemspec` for + `bundler-audit` and find nothing +- **Origin:** cubic learning 995f36ef; PR #8 + +### `CLAUDE.md`'s Ruby floor is the gemspec's floor + +- **Rule:** the Tech Stack section states `Ruby >= 3.2` with the CI matrix + `3.2, 3.3, 3.4`, matching `locallingo.gemspec`'s + `required_ruby_version = ">= 3.2"`, `.rubocop.yml`'s + `TargetRubyVersion: 3.2` and `ci.yml`'s matrix. +- **Holds because:** `CLAUDE.md` is what steers an agent's syntax choices. A + floor stated one version too high licenses 3.3-only syntax, which passes + locally and fails exactly one CI cell — the slowest failure to read. +- **Where:** `../../CLAUDE.md` (Tech Stack); the four sources above +- **Proven by:** no test; CI's 3.2 cell is the backstop +- **Origin:** cubic learning b09e69cf; PR #8 + +### `.claude/commands/review-pr.md` fetches with `gh` and declares `allowed-tools` + +- **Rule:** the PR-review command gets its PR data through `gh pr view`, + `gh pr diff` and `gh pr checks`, and declares an `allowed-tools:` frontmatter + list matching its sibling PR commands. +- **Holds because:** this repository configures no MCP server (there is no + `.mcp.json`), so a command that reached for `mcp__github__*` would fail at its + first step wherever the GitHub MCP server is not enabled; and a command with no + `allowed-tools` runs the model with unrestricted tool access, which is not what + the other PR commands do. +- **Where:** `../../.claude/commands/review-pr.md` (frontmatter, Workflow §1) +- **Proven by:** no test +- **Origin:** cubic learning 9145fb7d; PR #8 + +### An issue or PR body is written to a file and passed with `--body-file` + +- **Rule:** a body that carries code fences is written to a temp file first and + passed as `gh issue create --body-file <file>` / `gh pr create --body-file + <file>`. Never an inline heredoc with `--body`. Where the procedure names the + command, it names the one it is actually running — `gh issue create` for a + plan, `gh pr create` for a PR. +- **Holds because:** the shell interpolates inside a `"…"` body argument, so + backticks in a fenced block are executed or eaten and the posted text is not + the text that was written. Plans and PR bodies in this repository are mostly + fenced YAML and Ruby, so this is the normal case, not the edge case. +- **Safe direction:** a temp file costs one line and is never wrong. +- **Where:** `../plans/README.md`; `../workflow.md` → Branches and PRs +- **Proven by:** no test — it is a procedure, not code +- **Origin:** merged PR #8 review thread `132a4bce` on the retired + `.claude/commands/plan.md`, accepted by the maintainer ("applied the + suggestion") and fixed in `4eff12d`. The command is gone; the rule moved to the + workflow profile, which `/lode:plan` and `/lode:lfg` read. + +### Not a bug: `docs/app/assets/stylesheets/tailwind.sources.css` commits absolute gem paths and a Ruby version + +- **Rule:** portability findings about that file — machine-specific + `/Users/.../.gem/ruby/<version>/gems/...` paths, or a Ruby version that does + not match `docs/Dockerfile` — are not defects here, and are not to be raised + again. +- **Holds because:** the file is output, not input. `docs/bin/build-css` + regenerates it from `bundle show daisyui` and `bundle show docs-kit` on every + run and aborts if either fails, and `docs/.dockerignore` excludes it from the + image, so no build ever consumes the committed copy. Gitignoring it instead is + a fleet-wide docs-kit convention change (generator plus a `--sync` migration), + tracked at zoolutions/docs-kit#71 — not a one-site edit. +- **Where:** `../../docs/app/assets/stylesheets/tailwind.sources.css`, + `../../docs/bin/build-css`, `../../docs/.dockerignore` +- **Proven by:** no test +- **Origin:** cubic learning 3398d197; PR #6 (rejected with reasons by the + maintainer) diff --git a/lode/rubocop-cops/summary.md b/lode/rubocop-cops/summary.md new file mode 100644 index 0000000..0149d3d --- /dev/null +++ b/lode/rubocop-cops/summary.md @@ -0,0 +1,78 @@ +# RuboCop cops + +Two cops the gem ships for *host apps* to run. They are the one subsystem that +never executes during a `lingo` command. + +## 1. How they load + +`lib/locallingo/rubocop.rb` (21 lines) is the entry point a host app names in +its `.rubocop.yml` as `require: locallingo/rubocop`. It `require`s `rubocop` +itself and then the two cop files, and defines +`Locallingo::RuboCop::CONFIG_DEFAULT`, the absolute path to +`config/default.yml`, for `inherit_gem`. + +This is why `rubocop` is a **development** dependency in the gemspec +(`add_development_dependency "rubocop", ">= 1.75", "< 2"`) and not a runtime one: +nothing on the `lingo` path requires this file, so a host app never pulls +RuboCop into its runtime. The gemspec carries a comment saying exactly that, and +`.rubocop.yml` excludes `locallingo.gemspec` from +`Gemspec/DevelopmentDependencies` so the arrangement survives the linter. + +`config/default.yml` (47 lines) ships both cops `Enabled: true` with `Include` +lists and, for the first cop, the default `ScopedDirectories`. It also carries a +commented recommendation to disable `Rails/I18nLazyLookup` and +`Rails/I18nLocaleTexts`, which fight the convention the first cop enforces. + +## 2. `Locallingo/RelativeI18nKey` (`lib/rubocop/cop/locallingo/relative_i18n_key.rb`, 99 lines) + +Flags `t(".key")` — a relative key relying on Rails' lazy lookup — and +autocorrects to a fully-qualified key. + +The node matcher is `(send nil? :t (str $_) ...)`, so it only sees a receiverless +`t` whose first argument is a **string literal**; `I18n.t(".x")` and +`t(some_var)` are invisible to it. `on_send` (`46-57`) then requires +`key.start_with?(".")`. + +`lazy_lookup_scope` (`77-91`) mirrors Rails' own scope derivation: + +1. Take `processed_source.file_path`, return nil if it is nil. +2. Strip everything through the first `app/`. +3. The first path segment must be in `scoped_directories` — otherwise **return + nil**. +4. Drop that segment, drop `.rb`, drop a trailing `_controller` or `_mailer`, + and `tr("/", ".")`. +5. Append `.<enclosing def's method name>` when the node is inside a `def`. + +Step 3 is the design decision worth keeping: when the path maps to no known +convention the cop **still reports the offense but performs no correction** — +`qualify` returns nil and the corrector block `next`s. Flag-and-leave-it beats +guessing a wrong key. + +`scoped_directories` (`61-65`) reads `cop_config["ScopedDirectories"]` and falls +back to `DEFAULT_SCOPED_DIRECTORIES` (8 entries) when the configured list is +empty, so an app cannot accidentally disable correction by configuring `[]`. + +## 3. `Locallingo/StrftimeInView` (`lib/rubocop/cop/locallingo/strftime_in_view.rb`, 51 lines) + +Flags any `.strftime` send with `RESTRICT_ON_SEND = %i[strftime]`, offending on +`node.loc.selector` (the method name, not the whole expression). No autocorrect — +the right named format is a judgment call. + +One exemption, `in_html_input_context?` (`43-47`): the node has an ancestor +`pair` whose key is `:value`. HTML `datetime-local` input values must follow the +HTML spec, not locale display formatting. The guard is `pair.key.respond_to?(:value)` +before reading it, so a non-literal hash key does not raise. + +Scoping is left to the standard `Include`/`Exclude`; `config/default.yml` limits +it to `app/views/**/*.rb` and excludes `app/views/**/*_mailer/**/*.rb`. + +## 4. Complexity exemption + +`.rubocop.yml` excludes `lib/rubocop/cop/**/*` from `Metrics/CyclomaticComplexity` +and `Metrics/PerceivedComplexity` with the reasoning in a comment: AST walkers +are inherently branchy. The other metric caps (`MethodLength: 35`, +`AbcSize: 45`, `ClassLength: 300`) still apply to them. + +## See also + +- `../testing-and-ci/summary.md` — the two cop spec files diff --git a/lode/state-and-validators/summary.md b/lode/state-and-validators/summary.md new file mode 100644 index 0000000..498e62c --- /dev/null +++ b/lode/state-and-validators/summary.md @@ -0,0 +1,98 @@ +# Drift state and validators + +## 1. `StateStore` (`lib/locallingo/state_store.rb`, 66 lines) + +The whole persistence layer. `#initialize` (`18-21`) `mkdir_p`s `state_dir`, so +constructing one has a filesystem side effect. + +`StateStore.hash` (`24-26`) is `format("%08x", Zlib.crc32(text.to_s))` — 8 hex +characters. It is a change fingerprint, not a checksum with security properties, +and `Validators::Outdated` and `Validators::ManualEdits` call the class method +directly while `Manager` goes through the instance delegate (`hash`, line 28). + +`#load(locale)` (`31-41`) merges every `*.<locale>.json` in `state_dir` into one +hash (later files in glob order win a key collision), and converts a `JSON::ParserError` into +`Locallingo::Error` carrying `"Corrupted state file: … This would cause state +loss. Fix the JSON manually or restore from git."` The error names the parse +problem but **not the file** — the message is the same whichever namespace file +is broken. + +`#save(locale, locale_state)` (`46-64`) does three things in order: + +1. Regroup the flat state by `key.split(".").first`. +2. For each namespace, build `"#{JSON.pretty_generate(keys.sort.to_h)}\n"` and + `next if` the file already contains exactly those bytes. This is the + no-churn rule: keys sorted, pretty-printed, one trailing newline, and an + unchanged file is not touched at all (its mtime included). +3. Delete every `*.<locale>.json` whose namespace is no longer in the state. + An empty `locale_state` therefore deletes *all* of that locale's files. + +`save` writes with a plain `File.write`, not a write-then-rename — an +interrupted save can leave a truncated JSON file, which the next `load` reports +as corrupted rather than silently dropping. + +## 2. The four validators + +All live in `lib/locallingo/validators/`, all expose `#call(**)` returning an +array of `{type:, locale:, key:, suggestion:}` hashes, all are gated in +`Manager#validate` by `config.validator_enabled?`. The four types are exactly +the four keys of `Reporter::TYPE_ICONS`. + +| Validator | File | Input | Emits | Default | +|---|---|---|---|---| +| `Missing` | `missing.rb` (24) | `source:`, `target:`, `locale:` | `:missing` | on | +| `Outdated` | `outdated.rb` (49) | `source:`, `locale_state:`, `locale:` | `:outdated` | on | +| `DuplicateValues` | `duplicate_values.rb` (42) | `source:` only | `:duplicate_value` | off | +| `ManualEdits` | `manual_edits.rb` (39) | `target:`, `locale_state:`, `locale:` | `:manual_edit` | off | + +**`Missing`** is `source.keys - target.keys`; the suggestion is +`Run: lingo translate --locale <locale>`. + +**`Outdated`** keys off `outdated_keys` (`outdated.rb:28-33`): +`stored = locale_state.dig(key, "source_hash"); key if stored && stored != hash`. +The `stored &&` is why a key with no state entry is never outdated. Its +suggestion branches on the `manual` flag (`suggestion_for`, `39-46`): a curated +key is told to update the value by hand and re-run `accept-edits`, never to +`--force-key` over it. + +**`DuplicateValues`** groups the source's `activerecord.attributes.*` keys by +value, then reports any key whose value matches one of them — skipping every key +under `activerecord.` (`AR_PREFIX`, not just the attributes prefix), because +Rails owns that namespace and a model name equal to an attribute label is +intentional. It hard-codes `locale: "en"` in the violation rather than reading +`config.source_locale`, so an app whose source locale is not `en` gets the wrong +locale label on this violation type only. + +**`ManualEdits`** reports a target key whose entry is a Hash, is not already +`manual`, has a `target_hash`, and whose current value hashes differently. Its +suggestion is the exact command to run: +`lingo accept-edits --locale <locale> --key <key>`. + +Three of the four take `cli_name:` in the constructor so the suggestion text +names whatever binary invoked them; `DuplicateValues` takes no constructor +argument because its suggestion names keys, not commands. + +## 3. The protection invariant, end to end + +A key is protected when its state entry carries `"manual" => true`. The complete +set of writers and what each does to it: + +| Writer | `manual` | +|---|---| +| `Manager#accept_edits!` | sets it | +| `Manager#update_locale_state` | re-adds it if it was already there | +| `Manager#sync_locale_state` | copies the existing entry (`existing.dup`) and only touches `source_hash` / backfills `target_hash` | +| `StateStore#save` | serialises whatever it is handed | + +Nothing clears it. `Manager#determine_keys_to_translate` reads it to exclude +protected keys from both the default path and `--force`; only `--force-key` +translates one, and `update_locale_state` puts the flag straight back. + +The second half of the invariant is `target_hash`: `sync_locale_state` sets it +with `||=`, so a sync backfills a baseline for a hand-added translation but can +never absorb the drift of one that already had a baseline. + +## See also + +- `../translation/summary.md` — the Manager paths that call these +- `../review/commands-and-docs.md` — accepted review rules diff --git a/lode/summary.md b/lode/summary.md new file mode 100644 index 0000000..851bc7c --- /dev/null +++ b/lode/summary.md @@ -0,0 +1,46 @@ +# Locallingo + +A development-time Ruby gem that keeps a Rails app's `config/locales/**/*.yml` +translated, and ships the `lingo` CLI (`exe/lingo` → `Locallingo::CLI`) that does +it. Its unit of work is the **flat dotted key** (`accounts.show.title`): every +locale file is flattened on load, compared against the source locale, and written +back nested. + +The gem is two halves that share nothing but the `Configuration`: + +- **Offline half** — `status`, `validate`, `sync`, `hash`, `accept-edits`, + `quality` without `--ai`, `fix-quality`, and the two RuboCop cops. No provider + credentials, no network. +- **LLM half** — `translate` and `quality --ai`. Both go through + `Locallingo::Providers::RubyLLM`, the single place the gem talks to a model. + +What makes a re-run cheap is the **drift state** under `state_dir` (default +`.i18n-state/`): one JSON file per top-level namespace and locale, mapping each +key to the CRC32 of the source value it was translated from, and optionally the +CRC32 of the target value plus a `manual: true` flag. A key whose stored +`source_hash` still matches is skipped; one whose hash moved is *outdated*; one +absent from the target is *missing*. + +Three invariants govern every change: + +1. **Hand-edit protection is never dropped.** No command clears `manual: true`, + and no command recomputes a `target_hash` over a value it did not itself + write: `sync_locale_state` sets it with `||=` (backfill only), and + `update_locale_state` writes a fresh one only for a translation it just + merged. `update_locale_state` re-stamps a `manual` flag it found; + `accept_edits!` is the only writer that sets one. Unprotecting a key means + editing the state JSON by hand. +2. **State writes are idempotent and newline-terminated.** `StateStore#save` + writes `"#{JSON.pretty_generate(keys.sort.to_h)}\n"` and skips a namespace + file whose bytes already match, so an unrelated namespace never churns a diff. +3. **Corrupted state raises, never resets.** `StateStore#load` turns a + `JSON::ParserError` into `Locallingo::Error` with a "fix it or restore from + git" message; losing drift state is worse than a failed run. + +Around those: everything app-specific is `.locallingo.yml` (`defaults:` plus +optional per-`packages:` overrides, deep-merged over the shipped +`config/locallingo.default.yml`); provider keys are resolved lazily from +`Locallingo.configure` → host `RubyLLM.configure` → ENV and never persisted or +logged; the eight legacy `--flag` CLI forms keep working behind a deprecation +notice, in first position only; and `lib/locallingo/version.rb` moves only in +`rake release[X.Y.Z]`, never in a PR. diff --git a/lode/terminology.md b/lode/terminology.md new file mode 100644 index 0000000..047355c --- /dev/null +++ b/lode/terminology.md @@ -0,0 +1,56 @@ +# Terminology + +The words this repository uses, and what they mean in the code. + +- **flat key** — a translation key in dotted form with bracket array indices, + `accounts.show.title`, `items[0].name`. Produced by `KeyFlattener.flatten` and + consumed everywhere; the nested hash only exists at YAML read/write time. +- **namespace** — `key.split(".").first`. It decides which state file a key + lands in (`StateStore#save`) and which locale file a translation is merged + into (`Manager#merge_translations`). It is derived from the key, never from + the file name, so a key inside `config/locales/en.yml` still routes to + `<its-first-segment>.<locale>.json`. +- **source locale** — `config.source_locale` (default `en`): the locale + translations are authored in and translated *from*. +- **target locale** — an entry of `config.target_locales`: a locale Locallingo + fills in. +- **drift state / state** — the JSON under `state_dir` (default `.i18n-state/`). + One file per namespace *and* locale: `accounts.de.json`. An entry is + `{"source_hash" => ..., "target_hash" => ..., "manual" => true}`, the last two + optional. +- **source_hash** — `StateStore.hash` (8 hex chars, `Zlib.crc32`) of the source + value the key was last translated from. The drift fingerprint. +- **target_hash** — the same hash over the *target* value Locallingo wrote, so a + later hand edit is detectable. +- **manual flag** — `"manual" => true` on a state entry: the target value is + hand-curated. Set only by `accept_edits!`; cleared by nothing. +- **outdated** — the stored `source_hash` exists and differs from the current + source value's hash (`Validators::Outdated#outdated_keys`). A key with *no* + state entry is not outdated. +- **missing** — present in the source hash, absent from the target hash + (`Validators::Missing#call`). +- **violation** — a Hash `{type:, locale:, key:, suggestion:}` returned by a + validator. The four types are `:missing`, `:outdated`, `:duplicate_value`, + `:manual_edit` (the four keys of `Reporter::TYPE_ICONS`). +- **suggestion (quality)** — a different shape entirely: a Hash with + `key:, text:, locale:, category:, issue:, severity:, source:` and, when + auto-fixable, `fix: { from:, to: }`. Produced by `QualityChecker`. +- **fixable** — a quality suggestion carrying `:fix`. Only + `Quality::StaticRules.universal_fixes` and `Quality::BritishSpellings.check` + emit one, so only those are rewritten by `fix-quality`. +- **strict tier** — `strict` or `strict_all` under the config's `strict:` block: + the list of violation *types* that make `validate` exit 1 under `--strict` / + `--strict-all` (`Reporter#exit_code`). +- **validator** — one of the four classes in `lib/locallingo/validators/`, each + gated by `config.validator_enabled?(name)`. +- **package** — an entry of the config's `packages:` list, keyed by `path`. With + `--package engines/billing`, that entry deep-merges over `defaults` and + `base_path` becomes `<root>/engines/billing`. +- **exceptions** — `<state_dir>/exceptions/<locale>.yml`, a flat-loadable YAML + of keys `translate` must not touch. Read by `Manager#load_exceptions`; it has + no config key and is not mentioned in the README or the docs site. +- **setup file** — `.locallingo.rb` at the project root, `load`ed by the CLI + before dispatch so a standalone run can call `Locallingo.configure`. +- **legacy flag** — one of the eight `--status`/`--translate`/… forms in + `CLI::LEGACY_FLAGS`, accepted only in first position and answered with a + `[deprecated]` line on stderr. diff --git a/lode/testing-and-ci/summary.md b/lode/testing-and-ci/summary.md new file mode 100644 index 0000000..bcc0425 --- /dev/null +++ b/lode/testing-and-ci/summary.md @@ -0,0 +1,107 @@ +# Testing, CI and release + +## 1. The suite + +RSpec with SimpleCov. 11 spec files, 101 examples (`grep -cE '^\s*it[ (]'` +across `spec/**/*_spec.rb`). `.rspec` is `--require spec_helper --format +documentation --color`. + +| Spec file | Lines | Examples | Subject | +|---|---|---|---| +| `spec/locallingo/manager_spec.rb` | 456 | 23 | status, validate, translate, accept-edits, sync | +| `spec/locallingo/cli_spec.rb` | 186 | 12 | argument parsing and dispatch, in a tmp app | +| `spec/locallingo/providers/ruby_llm_spec.rb` | 125 | 12 | credential resolution and `chat` | +| `spec/locallingo/configuration_spec.rb` | 105 | 8 | the three-way merge, packages, ERB | +| `spec/locallingo/quality_checker_spec.rb` | 102 | 9 | static checks, `fix!` | +| `spec/rubocop/cop/locallingo/relative_i18n_key_spec.rb` | 78 | 7 | the cop + autocorrect | +| `spec/locallingo/settings_spec.rb` | 72 | 11 | String and callable keys | +| `spec/locallingo/state_store_spec.rb` | 67 | 5 | save/no-churn/trailing newline/pruning | +| `spec/locallingo/key_flattener_spec.rb` | 39 | 5 | flatten / set_nested_value | +| `spec/locallingo/json_extraction_spec.rb` | 36 | 6 | fenced, prose-wrapped, brace-in-string | +| `spec/rubocop/cop/locallingo/strftime_in_view_spec.rb` | 31 | 3 | the cop + the `value:` exemption | + +There is **no spec file for `Reporter` and none for the four validators** — both +are exercised only through `manager_spec.rb` and `cli_spec.rb`. A change to a +violation's shape or to a printed line is caught, if at all, by those two. + +## 2. Fixtures and stubs + +Two support files, both auto-required by `spec_helper.rb`'s +`Dir[File.join(__dir__, "support", "**", "*.rb")]`: + +- `spec/support/locale_fixtures.rb` — `with_app(locales:, config:, raw_config:)` + builds a throwaway app in `Dir.mktmpdir` with `config/locales/<ns>.<loc>.yml` + files and a `.locallingo.yml`, and yields the root. Plus `config_for`, + `write_state(root, "g.de.json", entries)` and `read_state`. Included globally + via `config.include LocaleFixtures`. +- `spec/support/ruby_llm_stub.rb` — `stub_llm_chat { |payload:, model:, + instructions:| … }` and `stub_llm_missing_credentials`. Both use + `allow_any_instance_of(Locallingo::Providers::RubyLLM)`, which is why + `.rubocop.yml` excludes `spec/support/**/*` from `RSpec/AnyInstance`. The rule + the stub enforces: **specs never make a network call**. + +`spec_helper.rb` also sets `config.order = :random` with `Kernel.srand +config.seed`, `disable_monkey_patching!`, and an `after` hook calling +`Locallingo.reset_settings!` so a configured key cannot leak between examples. + +SimpleCov is started with `add_filter "/spec/"` and **no `minimum_coverage`** — +nothing in the repo enforces a coverage floor, despite +`.claude/rules/testing.md` naming 80% / 100% targets. + +## 3. Running it + +`bundle exec rake` is `spec` then `rubocop` (`task default: %i[spec rubocop]`), +and it is exactly what CI runs. The RuboCop rake task is scoped to +`exe lib spec Rakefile Gemfile locallingo.gemspec` — the `docs/` app has its own +`.rubocop.yml` and its own bundle and is not covered. + +`Gemfile.lock` is gitignored at the repo root, so every `bundle install` +re-resolves; `docs/bun.lock` is the only tracked lockfile in the repository. + +## 4. CI + +`.github/workflows/ci.yml` — one job, `rake`, on `push` to `main` and on every +`pull_request`, Ruby `3.2`, `3.3`, `3.4`, `fail-fast: false`, with a +`concurrency` group that cancels superseded runs on the same ref. Each cell is +`actions/checkout@v4` → `ruby/setup-ruby@v1` with `bundler-cache: true` → +`bundle exec rake`. Nothing else runs on a PR: **the `docs/` app has no PR CI**, +since `deploy-docs.yml` fires only on a published release or a manual dispatch. + +A failure in exactly one Ruby cell is a version-specific bug, not a flake: the +gem's floor is Ruby 3.2 (`required_ruby_version`, `TargetRubyVersion: 3.2`), so +3.3+/3.4-only syntax breaks the 3.2 cell first. + +`.github/workflows/release.yml` runs on a published release: `test` (the same +rake matrix) → `build` (verify the tag matches `Locallingo::VERSION`, build with +`--strict`, unpack and assert no `.git*`/`*.gemspec`/`spec`/`test` entries, +generate sha256+sha512) → `publish-rubygems` (OIDC trusted publishing in the +`rubygems` environment, Sigstore-signed, skipped if the version is already on +RubyGems) → `upload-release-assets`. + +`.github/workflows/deploy-docs.yml` calls +`zoolutions/docs-kit/.github/workflows/deploy.yml@main` with +`image: zoolutions/locallingo`, `service: locallingo`. + +## 5. Release + +`rake release[X.Y.Z]` (`Rakefile:33-92`) and nothing else moves +`lib/locallingo/version.rb`. It aborts unless the branch is `main` and the tree +is clean, bumps the version file, runs `bundle install --quiet` and +`gem build --strict` as a pre-flight, commits `chore: bump version to X.Y.Z`, +pushes `main` directly, and creates the GitHub release with +`gh release create vX.Y.Z --generate-notes --target main`, which is what fires +both release workflows. `rake release[pre]` keeps the current version and +`--prerelease`s the release; any version string matching `alpha|beta|rc|pre` is +treated as a prerelease. + +Releases therefore land on `main` **without a PR**, which is why an ordinary +feature branch must never touch `version.rb`. + +`rake build` builds the gem, unpacks it into `/tmp/locallingo-verify`, prints +the file list and cleans up — the local rehearsal of the release job's contents +check. + +## See also + +- `../workflow.md` — the commands and the CI facts a workflow skill needs +- `../docs-site/summary.md` — the docs app's own tooling diff --git a/lode/translation/summary.md b/lode/translation/summary.md new file mode 100644 index 0000000..156c696 --- /dev/null +++ b/lode/translation/summary.md @@ -0,0 +1,144 @@ +# Translation engine: `manager.rb` and `key_flattener.rb` + +`Locallingo::Manager` (`lib/locallingo/manager.rb`, 424 lines — the largest file +in the gem) orchestrates every locale-file operation. It owns a `Configuration`, +a `StateStore`, a `Providers::RubyLLM` and a `Logger`, all built in `#initialize` +(`manager.rb:34-43`). + +## 1. Reading and writing locale files + +`load_locale_translations` (`manager.rb:376-395`) globs two patterns under +`config.locales_dir` — `**/*.<locale>.yml` then `<locale>.yml` — loads each with +`YAML.load_file`, skips anything whose top-level `[locale]` key is absent, and +merges `KeyFlattener.flatten(content[locale])` into one flat hash. **Only String +values survive** (`translations[key] = value if value.is_a?(String)`), so an +integer or boolean leaf is invisible to every command. Later files win on a key +collision, in glob order. + +`QualityChecker#load_locale_translations` (`quality_checker.rb:177-196`) is a +byte-for-byte copy of this method. Changing one and not the other is the +classic bug here. + +Writing back is `merge_translations` (`manager.rb:359-367`): group the translated +pairs by namespace, find the file with `find_or_create_locale_file` +(`manager.rb:369-372` — `Dir.glob("**/<namespace>.<locale>.yml").min`, else +`<locales_dir>/<namespace>.<locale>.yml`), `YAML.load_file` it, set each key with +`KeyFlattener.set_nested_value`, and `File.write(file, existing.to_yaml)`. That +rewrites the whole file through Psych, which is why the shipped default +`after_translate` hook is `bundle exec i18n-tasks normalize -p`. + +`KeyFlattener` (`key_flattener.rb`, 114 lines) is the only place the nested↔flat +conversion lives. `flatten` (`17-37`) indexes arrays as `key[0]`, recurses into +hashes inside arrays as `key[0].sub`, and **drops array items that are neither +String nor Hash**. `parse_key_segments` (`62-88`) reverses that, including +consecutive indices (`matrix[0][1]`); `navigate_or_create` (`92-108`) creates the +intermediate hashes and arrays, padding arrays with `nil`. + +## 2. The seven public operations + +| Method | Lines | Reads | Writes | +|---|---|---|---| +| `status` | `46-64` | source + each target + each target's state | nothing | +| `validate` | `68-88` | same | nothing | +| `translate!` | `91-98` | same, plus exceptions | locale YAML + state | +| `accept_edits!` | `106-130` | same | state | +| `sync_state!` | `143-156` | source + each target + all state | state | +| `source_hash` | `133-135` | source | nothing | +| `run_after_translate_hooks` | `159-164` | config | whatever the hooks do | + +`status` reports `outdated` through `outdated_validator.outdated_keys` +regardless of `validators.outdated` — the config gate applies to `validate` +(`manager.rb:79-81`), not to `status`. `validate` gates all four validators, +runs `DuplicateValues` once over the source only, and the other three per target +locale. + +`run_after_translate_hooks` shells out with bare `system(command)` inside +`Dir.chdir(config.root_path)`; **the return value is discarded**, so a failing +hook does not fail the run and is not reported. The commands come from +`.locallingo.yml`, which is app-author-controlled, never from translated text. + +## 3. How `translate` decides what to send + +`determine_keys_to_translate` (`manager.rb:332-341`) is the whole policy: + +``` +manual_keys = state entries with "manual" => truthy +force: source.keys - manual_keys # exceptions ignored +force_keys: force_keys & source.keys # manual keys included +default: (missing + outdated) - exceptions - manual_keys +``` + +Three consequences worth holding on to: `--force` respects the manual flag but +**not** the exceptions file; `--force-key` respects neither, and is the only way +to retranslate a protected key; and a key with no state entry is never +*outdated*, only ever *missing*. + +Exceptions come from `load_exceptions` (`manager.rb:397-408`) reading +`<state_dir>/exceptions/<locale>.yml` and flattening `content[locale]`. It +rescues `StandardError` into `{}` with a warn-level log line — the one place in +the gem that swallows an error, on the grounds that an unreadable opt-out file +should not stop a translate run. + +## 4. The batch loop and its retries + +Three nested layers, each with its own budget (`MAX_RETRIES = 3`, +`MAX_MISSING_RETRIES = 2`, `BASE_SLEEP_DURATION = 1.0`): + +1. `translate_keys` (`259-276`) slices the keys by `config.batch_size` and + sleeps 1s between slices. A key the model answered with a missing or empty + value goes on `failed`. +2. `translate_batch` (`278-301`) makes the call. It rescues `StandardError`, + sleeps `1.0 * 2**retries` and retries up to 3 attempts, then logs + `Translation batch failed after 3 retries` at error level and **returns `{}`** + — so an exhausted batch becomes "every key in it failed", not an exception. +3. `translate_with_missing_retries` (`239-257`) re-sends the failed keys up to + twice, sleeping `1.0 * 2**round` first, then logs each still-failing key at + warn level. + +`translate_locale` (`213-237`) then writes only `translated.except(*failed)`. +A locale where everything failed still prints `Completed <locale>: 0 translated, +<n> failed` and exits 0 — a failed translation is a log line, not an exit code. + +The prompt is built per locale by `translation_prompt` (`303-318`) from +`config.language_name`, `config.context`, `config.placeholder_style`, the +glossary and the per-locale language guide, and it ends by demanding a raw JSON +object with no fences — which is why `JsonExtraction` exists +(`../config-and-providers/summary.md`). + +## 5. State updates: who may write what + +- `update_locale_state` (`343-357`), after a successful translate: writes + `source_hash` and `target_hash` fresh, and re-adds `"manual" => true` **only + if the existing entry had it**. A `--force-key` on a protected key therefore + replaces the value but keeps the protection. +- `sync_locale_state` (`171-189`), for each target: refreshes `source_hash` for + every key present in both source and target, sets `target_hash` with `||=` + (backfill only — never recompute), and deletes entries whose key is absent + from the *target* file. The source locale's own state (`143-156`) is refreshed + and pruned against the source file. +- `accept_edits!` (`106-130`): the only writer of `"manual" => true`. It builds + a plan per locale first, calls `ensure_keys_matched!` (`203-211`) so an + explicit `--key` that matched nothing anywhere raises `Locallingo::Error`, and + only then writes. + +`keys_to_accept` (`191-201`) has three modes, checked in this order: +`keys:` (any named key present in both source and target, drifted or not), +`all:` (every target key present in the source), and the default — target keys +whose entry is a Hash, not already `manual`, and whose stored `target_hash` +exists *and* differs from the current value's hash. An entry with no +`target_hash` is invisible to the default mode, which is what `sync`'s backfill +exists to fix. + +Every state writer inside `Manager` is guarded by `unless dry_run` — the four +guards are in `translate_locale` (`230`), `accept_edits!` (`127`), `sync_state!` +(`149`) and `sync_locale_state` (`188`) — so `--dry-run` reaches the provider and +the file reads but never `StateStore#save` or `merge_translations`'s `File.write`. +`run_after_translate_hooks` is the exception: it has no `dry_run` guard of its +own and runs the hooks whenever it is called. Only `CLI#cmd_translate` +(`cli.rb:159-162`) keeps a dry run from calling it, so a programmatic caller that +builds a `Manager.new(dry_run: true)` and calls it directly still shells out. + +## See also + +- `../state-and-validators/summary.md` — `StateStore` and the four validators +- `../cli/summary.md` — which flag reaches which argument diff --git a/lode/workflow.md b/lode/workflow.md new file mode 100644 index 0000000..5baeed6 --- /dev/null +++ b/lode/workflow.md @@ -0,0 +1,164 @@ +# Workflow profile + +Everything the shared workflow skills (`/lode:lfg`, `/lode:review-pr`, +`/lode:finish-prs`, `/lode:debug-flaky`, `/lode:tdd`, `/lode:plan`) need to know +about this repository that is not already in `../CLAUDE.md`, `../.claude/rules/` +or the rest of `lode/`. + +## Commands + +| Purpose | Command | Notes | +|---|---|---| +| fast loop (one file) | `bundle exec rspec <spec file>` | no network, no services; `Dir.mktmpdir` per example | +| full suite | `bundle exec rake` | `spec` then `rubocop` — exactly what CI runs. No network, no services. Safe to run in two worktrees at once: every fixture app is a fresh tmpdir and nothing binds a port or a database | +| lint | `bundle exec rake rubocop` | scoped to `exe lib spec Rakefile Gemfile locallingo.gemspec`; `bundle exec rubocop -A <file>` to autocorrect | +| one CI cell locally | n/a — CI runs the same `bundle exec rake`; switch Ruby with your version manager (3.2, 3.3, 3.4) | | +| docs build / check | `cd docs && bin/rubocop`, `cd docs && bin/ci` (rubocop + bundler-audit + importmap audit + brakeman; there is no test step), `cd docs && bun run build:css` | separate bundle; never run the root `bundle exec rubocop` against `docs/` | +| run the app | `ruby -Ilib exe/lingo <command>` from inside an app directory, or `cd docs && bin/dev` for the docs site | | + +## Branches and PRs + +- Default branch: `main` +- Work branches: `feature/*`, `fix/*`, `refactor/*`, `ci/*`, `chore/*`, rooted + off fresh `origin/main` +- **Before switching away from the current branch, stop for work that would be + left behind.** `git status --porcelain` non-empty → stop and ask; never stash + silently. If the current branch is not `main`, check for committed-but-unpushed + commits: verify an upstream exists first (`git rev-parse --verify --quiet + @{upstream}`) and compare `@{upstream}..`, otherwise the branch was never + pushed and every commit on it is unpushed, so compare `main..`. Either listing + commits → stop and name the branch that would be left behind. +- Commits: conventional (`feat:`, `fix:`, `refactor:`, `perf:`, `docs:`, + `test:`, `chore:`, `ci:`); the body says why, not what +- **Never touch `lib/locallingo/version.rb` in a PR** — `rake release[X.Y.Z]` + owns it and pushes straight to `main` +- PR body sections, in order: Summary, Test plan, Deviations & judgment calls, + Gate. Write the body to a file and pass `--body-file`; a heredoc with code + fences gets mangled +- Merge policy: squash on `main` after green and approval; never rebase a + published branch — merge `main` forward into it +- Attribution: never add `Co-Authored-By: Claude`, "Generated with Claude Code" + or any similar AI attribution to a commit, PR body or issue comment + +## Layers + +| Layer | Files | Edit rule | +|---|---|---| +| CLI | `exe/lingo`, `lib/locallingo/cli.rb`, `lib/locallingo/reporter.rb` | owned here; every printed string is user-facing contract | +| Engine | `lib/locallingo/manager.rb`, `lib/locallingo/key_flattener.rb` | owned here; `manager.rb` is 424 lines against an 800 cap — new behaviour goes in a collaborator | +| State | `lib/locallingo/state_store.rb`, `lib/locallingo/validators/**` | owned here; the protection invariants live here | +| Quality | `lib/locallingo/quality_checker.rb`, `lib/locallingo/quality/**` | owned here | +| Config | `lib/locallingo/configuration.rb`, `lib/locallingo/settings.rb`, `config/locallingo.default.yml` | owned here; a new key needs a shipped default *and* a typed reader | +| Provider | `lib/locallingo/providers/ruby_llm.rb`, `lib/locallingo/json_extraction.rb` | owned here; the only SDK boundary | +| Cops | `lib/rubocop/cop/locallingo/**`, `lib/locallingo/rubocop.rb`, `config/default.yml` | owned here; runs in host apps, never in `lingo` | +| Version | `lib/locallingo/version.rb` | release-owned — never edited in a PR | +| Docs site | `docs/**` | owned here, separate bundle; a page needs its `Doc` registry line | +| Generated | `docs/app/assets/stylesheets/tailwind.sources.css`, `docs/app/assets/builds/*` | generated — edit `bin/build-css`, never the output | + +## Shapes + +Check a change against these before calling it done: + +- A key with **no state entry** (never translated) — not outdated, only missing +- A key whose state entry is not a Hash, or is a Hash with no `target_hash` +- A key already carrying `manual: true` +- A **non-String leaf** in a locale file (integer, boolean, nil) — dropped by the + loader, so it never reaches translate, validate or quality +- An **array-valued** key (`items[0]`, `items[0].name`) through flatten → set +- A locale file at `config/locales/<locale>.yml` (no namespace in the filename) + as well as `config/locales/<ns>.<locale>.yml`, and nested subdirectories +- A source locale that is not `en` (`DuplicateValues` hard-codes `"en"`) +- A locale with no builtin language name (falls back to the bare code) +- `--package <path>` scoping, which moves `locales_dir`, `state_dir` and the log +- A config file that is absent entirely, and one with `packages:` but no match +- `--dry-run` on every writer +- An LLM reply that is fenced, prose-wrapped, brace-carrying, an array, or empty +- Ruby 3.2 (the floor) — no 3.3+/3.4-only syntax + +## Constraints + +Reviewer suggestions that are wrong here, with the reason. + +| Suggestion | Why it is wrong here | +|---|---| +| "Bump the version in this PR" | `rake release[X.Y.Z]` owns `version.rb` and pushes to `main` without a PR | +| "Reset corrupted state and carry on" | state loss is unrecoverable; `StateStore#load` raises on purpose | +| "Recompute `target_hash` during sync" | that silently absorbs hand-edit drift — `||=` is deliberate | +| "Always rewrite the state file for consistency" | byte-identical files are skipped so unrelated namespaces never churn a diff | +| "Drop the legacy `--flag` forms" | they are supported with a deprecation notice; removing them is a breaking change | +| "Add `bundler-audit` / `rubocop` as a runtime dependency" | `rubocop` is a development dependency loaded lazily by `locallingo/rubocop`; `bundler-audit` is not a project dependency at all (see `review/commands-and-docs.md`) | +| "Gitignore `docs/app/assets/stylesheets/tailwind.sources.css`" | rejected upstream in docs-kit; see `review/commands-and-docs.md` | +| "Use 3.3+ syntax" | the gemspec floor and `TargetRubyVersion` are 3.2 | + +## Docs + +- User-facing docs live in `docs/app/views/docs/pages/`; the page-to-behaviour + map is `docs-site/summary.md` §2. A page also needs its `page "…"` line in + `docs/app/models/doc.rb` +- `README.md` carries the same facts in short form — a CLI or config change + updates it too +- Changelog: `CHANGELOG.md`, Keep a Changelog format, entries under + `## [Unreleased]` in an `### Added` / `### Changed` / `### Fixed` subsection +- A change to a CLI option updates the `cli` **and** `commands` pages; a new + config key updates `configuration` **and** `configuration-reference`; a change + to state semantics updates `drift-state` **and** `commands` +- Files that pin a version and drift after a release: none. The root + `Gemfile.lock` is gitignored and `docs/Gemfile.lock` is not committed; + `docs/bun.lock` is the only tracked lockfile + +## CI + +- Workflows: `ci.yml` (`bundle exec rake` on Ruby 3.2/3.3/3.4, on push to `main` + and every PR, `fail-fast: false`, concurrency-cancelled per ref); + `release.yml` (on a published release: test → build+verify → RubyGems trusted + publishing → release assets); `deploy-docs.yml` (on a published release or + manual dispatch, via docs-kit's reusable workflow) +- Matrix: Ruby only. A cell differs from local only by Ruby version +- Fetch a failure: `gh pr checks <PR>`, then + `gh run view <RUN_ID> --job=<JOB_ID> --log-failed` +- What "green" means: all three `rake (Ruby X.Y)` cells. A `rake` job fails on + whichever of spec or rubocop breaks first — read the log to see which half +- Known not-this-branch failures: none recorded +- Shared or rate-limited services the checks hit: none — the suite is fully + offline, so PRs can run concurrently +- **The `docs/` app has no PR CI.** A docs change is unverified until someone runs + `cd docs && bin/ci` locally + +## Flake sources + +- None observed. The suite makes no network call (the provider is stubbed in + `spec/support/ruby_llm_stub.rb`), binds no port and uses no database. +- The two structural risks, if a flake ever appears: `config.order = :random` + plus `Locallingo.settings` being process-global memoised state (the + `after { Locallingo.reset_settings! }` hook in `spec_helper.rb` is what keeps + that honest), and `Dir.chdir` inside `cli_spec.rb`'s `run_cli` helper, which is + not thread-safe if examples are ever parallelised. +- `Manager`'s retry paths call real `sleep` (`BASE_SLEEP_DURATION = 1.0`, doubled + per round) — a spec that exercises a retry without stubbing `sleep` is slow, + not flaky. + +## Conflicts + +| File | Rule | +|---|---| +| `docs/bun.lock` | never hand-merge: take the base's, then `bun install --cwd docs` | +| `CHANGELOG.md` | union under `## [Unreleased]`, keeping both sides' bullets, without duplicating the `### Added`/`### Changed`/`### Fixed` subheads | +| `lib/locallingo/version.rb` | a feature branch never edits it — take the base's, unless the branch's own commits show a deliberate release-prep bump | +| `config/locallingo.default.yml`, `config/default.yml` | usually different keys on each side: keep both, then confirm the result is YAML the gem still loads | +| `docs/app/models/doc.rb` | append-only registry — base order first, then the branch's new `page` lines | +| `spec/support/locale_fixtures.rb` | add a helper rather than reshaping an existing one; both sides' specs call it | +| everything else | source — merge semantically, never blanket `--ours`/`--theirs` | + +The root `Gemfile.lock` and `docs/Gemfile.lock` are untracked and cannot +conflict. + +## Verification + +- The manual check a user of a change would do: build a scratch app + (`config/locales/<ns>.en.yml` + `.locallingo.yml`), run + `ruby -Ilib exe/lingo status`, then the command you changed, and read both the + printed output and the resulting `.i18n-state/*.json` +- Stress iterations for a flake proof: 50 (`bundle exec rspec <file> --seed <n>` + across seeds, since order is random) +- Where evidence goes: `lode/tmp/` (git-ignored), unless the PR needs an + auditable trail