Sitelet https://github.com/lingodotdev/lingo.dev/pull/2197
Skip to content

fix(cli): make run --key match real keys and stop it poisoning i18n.lock - #2197

Merged
cherkanovart merged 3 commits into
mainfrom
fix/eng-1419-cli-key-matcher
Aug 20, 2026
Merged

cherkanovart merged 3 commits into
mainfrom
fix/eng-1419-cli-key-matcher

Conversation

@cherkanovart

@cherkanovart cherkanovart commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

Fixes ENG-1419. Two defects in run --key that have to ship together.

Both surfaced in this week's feedback triage. Two users reported the symptom as "--force does not compose with --key", which is the wrong reading. --force composes fine. --key is what breaks.

1. --key matched nothing

computeProcessableData filtered with a raw minimatch call, but flat buckets flatten nested keys with /. The example in the CLI's own help was --key auth.login, which matches zero keys. The task's processable set came out empty, the run skipped it, and the output read as a success: "4 from cache, 0 processed".

The intended semantics were already in the repo. matchesKeyPattern (packages/cli/src/cli/utils/key-matching.ts) does exact, separator-bounded prefix, and glob matching, and is what show and the locked/ignored/preserved filters use. It just was not wired into run --key.

Measured on the keys a flat bucket actually produces:

--key 'auth/login'          before -> nothing        after -> auth/login/title, auth/login/button
--key 'auth/login/*'        before -> both keys      after -> both keys
--key 'auth/logout/title'   before -> that key       after -> that key
--key 'auth.login'          before -> nothing        after -> nothing

Only prefix matching changes. Globs and exact keys behaved correctly before and still do. auth/login_url stays out, because a prefix has to stop at a separator.

The last row is why the help text changed: auth.login never matched anything and still does not. The help now documents /, states that prefixes stop at a separator, and gives an example that works.

2. A filtered run overwrote unrelated lockfile entries

persistChecksums wrote checksums for every source key, not just the translated subset, and delta.ts replaces the whole section. So a --key run marked the untouched majority as translated. In the reported case, 837 changed keys with a filter over 10 of them would have marked the other 827 as done for good.

The guard already existed for --target-locale. It now covers --key too.

These ship together on purpose: while --key matches nothing, the write is unreachable. Fixing the matcher alone is what would first make the data loss reachable.

Verification

Four tests, written before the fix. The prefix-matching one failed; globs and exact keys passed already and are kept as regression guards. The lockfile guard test failed without the change.

Also run end to end against the live API with a real project:

  1. Full run, lockfile written with all four keys.
  2. Changed every source string, then ran --key auth/login. Only the two login keys were retranslated. auth/logout/title kept the translation from step 1 rather than picking up its new source text, confirming it was filtered rather than silently translated. The lockfile was byte-identical to before.
  3. Plain run afterwards. It picked up auth/logout/title and settings/profile/name, which is what proves the lockfile was not poisoned. Had it been, those keys would have counted as translated and never been retried.

packages/cli suite: 953 passed. One unrelated failure, lockfile.spec.ts, is a beforeEach timeout under parallel load; the file passes on its own in 3.9s against a 10s hook budget and references nothing this PR touches. Typecheck clean.

Known gap, not fixed here

frozen.ts writes checksums for all source keys when a project has no lockfile yet, and that path does not go through persistChecksums, so the guard above does not reach it. A --key run on a project without a lockfile therefore still marks every key as translated. Found while verifying against a real project rather than from reading the code. Same defect class as point 2, one line to fix, deliberately left out of this PR rather than folded in silently.

Not verified: anything at the scale of the reported case. The end-to-end run used a four-key fixture, so the mechanism is confirmed and the volume is not.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved run --key matching for exact keys, separator-aware prefixes, nested paths, and glob patterns.
    • Corrected matching for encoded and flat-bucket keys, including hyphenated keys.
    • Prevented checksum calculation and updates during narrowed runs, avoiding incorrect translation status changes.
  • Documentation

    • Updated --key help text with supported slash-separated paths, prefix rules, and recursive glob examples.

@coderabbitai

coderabbitai Bot commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your current included review allowance is based on your included PR review attempts over the past 7 days.

Next review available in: 6 minutes

Limit details: You’ve used all 3 included reviews currently available. Your 40 included PR review attempts over the past 7 days set your current allowance at 3 reviews per hour.

You’re in a promotional period — use the checkbox below to run this review for free:

  • Run review for free

On-demand reviews are free for the next 31 days. After that, they cost $0.25 per reviewed file.

How can I continue?

Run this review now using the option above, or comment @coderabbitai review --use-credits.

You can also wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 626381be-a698-44a1-85a7-a2e24da8c2fa

📥 Commits

Reviewing files that changed from the base of the PR and between f1d53bc and c2bf228.

📒 Files selected for processing (2)
  • .changeset/eng-1419-cli-key-matcher.md
  • packages/cli/src/cli/cmd/run/frozen.ts

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4a1b23cb-9e20-471a-87ae-e5c6d7913a52

📥 Commits

Reviewing files that changed from the base of the PR and between 07cc70b and f1d53bc.

📒 Files selected for processing (7)
  • .changeset/eng-1419-cli-key-matcher.md
  • packages/cli/src/cli/cmd/run/_utils.ts
  • packages/cli/src/cli/cmd/run/estimate.spec.ts
  • packages/cli/src/cli/cmd/run/execute-checksums.spec.ts
  • packages/cli/src/cli/cmd/run/execute.ts
  • packages/cli/src/cli/cmd/run/index.ts
  • packages/cli/src/cli/utils/key-matching.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/cli/src/cli/cmd/run/index.ts
  • .changeset/eng-1419-cli-key-matcher.md

Included review availability: 1 review is currently available. Your included PR review attempts over the past 7 days set your current allowance at 4 reviews per hour.


📝 Walkthrough

Walkthrough

The CLI now applies flat-key matching semantics to run --key, including exact keys, slash-bounded prefixes, and globs. Narrowed runs skip checksum generation and persistence. Tests, help text, and the changeset document these updates.

Changes

CLI key filtering

Layer / File(s) Summary
Key pattern matching and documentation
packages/cli/src/cli/utils/key-matching.ts, packages/cli/src/cli/cmd/run/_utils.ts, packages/cli/src/cli/cmd/run/estimate.spec.ts, packages/cli/src/cli/cmd/run/index.ts, .changeset/eng-1419-cli-key-matcher.md
computeProcessableData uses matchesFlatKeyPattern with decoded patterns. Tests cover encoded prefixes, globs, exact keys, and hyphenated keys. Help text and the changeset describe the updated semantics.
Filtered checksum persistence
packages/cli/src/cli/cmd/run/execute.ts, packages/cli/src/cli/cmd/run/execute-checksums.spec.ts
narrowsRun detects key and targetLocale filters. Narrowed runs skip checksum generation and persistence. Tests cover both filter types.

Estimated code review effort: 2 (Simple) | ~15 minutes

Merge Risk: 🟠 High · up to f1d53

A --key run on a project without an existing lockfile can still record checksums for every source key, causing later runs to skip untranslated work. Because this can silently poison lockfile state, the current head is not merge-ready until that path is fixed or explicitly accepted by the owner.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the two primary fixes to run --key matching and checksum lockfile corruption.
Description check ✅ Passed The description thoroughly explains the defects, changes, tests, end-to-end verification, and known gap, despite not using every template heading.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/eng-1419-cli-key-matcher

Comment @coderabbitai help to get the list of available commands.

@cherkanovart

Copy link
Copy Markdown
Contributor Author

Update after a structured review: five lenses in parallel, then every load-bearing finding handed to a separate agent whose job was to refute it. Two findings that looked like blockers did not survive that; two real ones did and are fixed in f1d53bc3.

Fixed after review

--key was over-selecting. matchesKeyPattern bounds a prefix on ., / and -. For flat keys the latter two are ordinary characters inside a segment, so --key sign-in also selected sign-in-error and overwrote its translation, and --key items also took items-0 and items.title. On origin/main only the exact key matched, so this was a regression introduced by wiring in the shared matcher.

matchesKeyPattern is left alone — locked/ignored/preserved rely on . and - deliberately, and its docstring cites inbox-0. --key now uses a sibling, matchesFlatKeyPattern, where / is the only separator that can bound a prefix. Measured:

'sign-in'      general -> sign-in, sign-in-error         flat -> sign-in
'items'        general -> items, items-0, items.title    flat -> items
'auth/login'   both    -> auth/login/title, auth/login/button
'auth/*'       both    -> auth/login_url
'auth/**'      both    -> all three

Live end-to-end confirmation: with all three source strings edited, run --key sign-in retranslated sign-in and left sign-in-error holding the translation of its old source text, proving it was filtered rather than silently translated.

The help text promised globs that match nothing. minimatch does not cross a /, so --key 'auth/*' reaches one level only — a user following "globs work too" and typing it for a nested key gets 0 processed, the same silent no-match this PR exists to fix. It also picked _ as its non-separator example, which implied - was safe when - was in fact a separator. Rewritten to state the / separator, that a prefix must end at one, and that a glob does not cross one.

The guard returned after the work was already done. createChecksums(sourceData) hashes every source value and runs before persistChecksums, inside the per-pattern file IO lock, so under --key it was computed, discarded, and paid for while the lock was held: 136 ms per task at 30k keys, roughly 2.7 s of hashing and 2.7 s of held lock across 20 locales. The flag check is now a named narrowsRun predicate consulted before the hashing at both call sites.

Comment and test cleanup. The guard comment stated the --key rationale above a condition that also covers --target-locale, whose reason is different; narrowsRun's docblock now names both. The two added tests that passed against origin/main are folded into an it.each with the case that actually pins the fix, the computeProcessableData fixtures are now /-joined like real keys instead of a second describe explaining that the first one's were not, the encoded alias is gone, and the touched files are prettier-clean.

packages/cli: 955 passed, 60 files. Typecheck clean.

Refuted, and worth recording

A --frozen regression. Real behaviour change, not a defect. --target-locale already fails --frozen on origin/main in the identical scenario, shipped deliberately in #992 for issue #991, so this extends established semantics to a third narrowing flag. More to the point, main's "pass" is a false green: with two keys edited and the run narrowed to one, origin/main returns exit 0 from a CI gate on a provably stale target file, while this branch fails. --frozen is documented to fail when the lockfile is out of sync, and it is. The claim that the error's lingo.dev lockfile advice re-poisons the lock is also wrong — that command skips when the section is already populated and stamps nothing.

Untranslated source text leaking into target files under --key. The mechanism is real: execute.ts merges sourceData under the target data, so unselected keys with no translation land as raw source. But the line is byte-identical to origin/main and equally reachable there via --key pricing/title and --key pricing/**, and main is worse — it stamps the lockfile in the same run rather than the next. It is also not permanent: --force or any source-value edit repairs it. Pre-existing, medium, and it wants its own ticket rather than this PR.

Known gaps, deliberately not in this PR

  • frozen.ts bootstraps a full lockfile for a project that has none, and that path does not route through persistChecksums, so a --key run on a fresh project still stamps every key.
  • Right after a --key run, a key written without a checksum cannot trigger delta.updated, so editing its source does not retranslate it until the next full run. Transient, low.
  • purge.ts:128 still hand-rolls minimatch(safeDecode(k), safeDecode(pattern)), so --key semantics now differ between run and purge.
  • _utils.ts compares decoded keys against decoded patterns while the four loader consumers of the shared matcher compare encoded against encoded via encodeKeys(); the pattern-side encode-then-decode is a no-op round trip worth collapsing.
  • Pre-existing performance, both in the touched function and dwarfing everything above: delta.added.includes(key) is a linear scan, measured 2982 ms versus 2.19 ms with Sets at 30k keys, and !!force is evaluated last so --force does not short-circuit it. minimatch's functional form compiles the pattern on every call — 148 ms versus 8 ms with the compile hoisted.
  • i18n.ts:479, in the deprecated i18n command, carries the same guard defect this PR fixes in run.

Not verified: anything at the scale of the reported case. The end-to-end runs used small fixtures, so the mechanism is confirmed and the volume is not.

@cherkanovart
cherkanovart merged commit 9db8613 into main Aug 20, 2026
8 checks passed
@cherkanovart
cherkanovart deleted the fix/eng-1419-cli-key-matcher branch August 20, 2026 18:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants