Skip to content

fix: land the v2.0.0 release-blocker batch (cache, formats, sync CLI, api/voice, hooks/glossary, docs) - #108

Merged
sjsyrek merged 34 commits into
mainfrom
fix/release-blockers-batch
Jul 29, 2026
Merged

fix: land the v2.0.0 release-blocker batch (cache, formats, sync CLI, api/voice, hooks/glossary, docs)#108
sjsyrek merged 34 commits into
mainfrom
fix/release-blockers-batch

Conversation

@sjsyrek

@sjsyrek sjsyrek commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Lands the seven remaining P1 release blockers for v2.0.0 (epic sync-8mqh): six audited defect batches fixed on parallel worktree branches plus the pre-release documentation corrections. Each branch passed the full suite independently; the integrated tree passes 262 suites / 5879 tests.

Changes Made

  • cache (sync-8mqh.18): cache enable/disable persist cache.enabled; cache stats reports the persisted state. Cross-process e2e regression test.
  • formats/yaml (sync-8mqh.12): alias expansion bounded by a node budget + depth cap in both walkers — the 1.2 KB alias bomb and self-referential anchors now fail fast (~350 ms / <1 ms) instead of hanging indefinitely.
  • formats/android+xliff (sync-8mqh.16): linear-time element scanning (new xml-scan.ts) replaces the quadratic lazy regexes — adversarial 4 MiB files drop from ~2 min to ~10 ms; well-formed output byte-identical. Android values containing ]]> are refused instead of breaking out of CDATA and injecting XML.
  • sync CLI (sync-8mqh.19): --locale/--sync-config actually reach the subcommand handlers (commander parent/child binding); out-of-config locales exit 7 (ConfigError) as documented; sync init --sync-config honored; BREAKING: the deprecated sync init --source-lang/--target-langs aliases are removed at the 2.0 cut, per the documented policy.
  • api/voice (sync-8mqh.25): automatic retry restricted to idempotent methods (POSTs replay only on provably-unsent errors; 429 retried for all methods with Retry-After) — no more 4x re-billing on client-side timeout; timeouts classify as NetworkError/exit 5 (was ValidationError/exit 6); 401 → AuthError; overall retry deadline (2× timeout, Retry-After waits excluded); document result endpoint never retried; voice reconnect no longer dead code; per-request trace IDs; new global --timeout/--max-retries flags.
  • hooks/glossary (sync-8mqh.32): hooks dir resolved via git rev-parse --git-path hooks (husky/core.hooksPath, worktrees, submodules); backups no longer clobbered; auto-glossary validates terms and is idempotent across runs; glossary entries reject separator chars, dialect sniffed per file; sync audit stops using source hashes as translation identities (adds additive missingTargets to the JSON output); TMS URL joining, credential redaction, timeout exit code, 32 MiB bounded response read; sync init writes atomically.
  • docs (sync-8mqh.17): Node-floor and CI-image corrections, retry-behavior accuracy (README, API.md, TROUBLESHOOTING), stale version stamps, nonexistent commands removed, four missing write languages, alias prose, CHANGELOG link. CHANGELOG Unreleased consolidated to one section per Keep-a-Changelog category and updated for everything above.

Test Coverage

~130 tests added or deliberately rewritten across unit, integration, and e2e tiers, including cross-process persistence, real-commander e2e option routing (spawnSync on the built CLI), nock server-side request-count assertions, real git worktree/submodule/husky fixtures, and 4 MiB adversarial parser inputs with elapsed-time bounds. Full suite: 262 suites / 5879 tests, exit 0; lint and type-check clean.

Backward Compatibility

Breaking (intended at the 2.0 cut): deepl sync init --source-lang/--target-langs removed (use --source-locale/--target-locales); POSTs are no longer retried on 5xx/timeout (behavior, not API); ]]> inside Android CDATA values and out-of-config --locale values now error instead of corrupting/no-op.
Maintained: all other CLI surfaces, JSON contracts (one additive field: sync audit --format json gains missingTargets), on-disk formats, exit-code contract (now matched more accurately).

Size: Large ✓

32 commits across 6 independently-tested fix branches plus docs; wide surface but each fix is narrowly scoped to its audited defect.

🤖 Generated with Claude Code

sjsyrek and others added 30 commits July 29, 2026 13:47
- README Development prerequisites said Node >= 20.19.0; engines.node is >=24.0.0
- SYNC.md GitLab CI recipe pinned node:20, which cannot install or run @deepl/cli
- README claimed 429 is not retried; the client retries with Retry-After/jittered backoff
- API.md advertised versions 1.2.0/1.1.0 and stale dates in header and footer
- API.md cited nonexistent 'deepl auth test' and 'deepl config unset' as exit-code sources
- write supported-language lists omitted ja, ko, zh, zh-Hans in API.md and README
- sync init alias-deprecation prose rewritten for the 2.0 cut (aliases removed)
- CHANGELOG exit-codes cross-link pointed at API.md instead of docs/API.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
CacheService always started enabled, so `cache stats` reported the
process-local default rather than the persisted `cache.enabled` value and
could never print "disabled" — not even after `config set cache.enabled
false`, which the translation path does honour.

CacheServiceOptions gains `enabled`, and the CLI's option factory (now
`resolveCacheOptions`) reads `cache.enabled` alongside `cache.ttl` and
`cache.maxSize`. Deriving it there rather than inside CacheService keeps
`--config <path>` overrides effective, since that flag replaces the
ConfigService the factory closes over.
… backups

Resolve the hooks directory with `git rev-parse --git-path hooks` so
`deepl hooks install` honours core.hooksPath (husky) and works when `.git`
is a gitdir pointer file (linked worktrees, submodules) instead of writing
to a `<gitdir>/hooks` path git never reads.

A repeat install no longer overwrites an existing `.backup`: the second
backup lands in a free numbered slot, and install now returns the hook and
backup paths so the CLI can print where the previous hook went.

Also resolve findGitRoot's start path to an absolute path — a relative
path made the parent-directory walk loop forever.
`cache disable` printed "✓ Cache disabled" and exited 0, but only flipped
an in-memory field in a process that exited immediately. Nothing reached
config, so the next invocation still had the cache enabled and translations
kept writing entries. `cache enable --max-size` appeared to work only
because that path already persisted `cache.maxSize`.

Both handlers now write `cache.enabled` to config, which is what
TranslationService reads. The in-memory enable()/disable() calls remain for
the rest of the current process.

The e2e regression test asserts the toggle from a separate CLI invocation:
an in-process test would have passed while the bug was live.
A translation containing "]]>" ended the CDATA section it was written into,
so its remainder became XML markup in the generated resource file. The
adjacent-section split that stood in for escaping could not survive the
round-trip either, since the deletion pass then rewrote the result.

Refuse such values with a ValidationError instead, matching the XLIFF
parser's stance on CDATA in translatable content.
Terms whose source or translation is empty, whitespace-only, or contains a
tab, carriage return or newline are now skipped with a warning naming the
key. Uploading them either split the term across TSV columns or fabricated
a different entry pair that DeepL then applied to live translations.

Dictionary comparison now normalizes both sides the way the glossary TSV
round trip does, so a clean glossary is created once and never re-uploaded.
Previously local terms were compared against trimmed API entries, which
could never be equal, so every sync issued an update.

Glossary failures are also isolated per locale and reported with the locale,
glossary name and term count instead of silently ending glossary sync for
every remaining locale.
The YAML parser resolves aliases while walking the document AST, a path
the yaml package's maxAliasCount guard does not cover. Nested alias
fan-out expanded exponentially and a self-referential anchor recursed
until the stack overflowed.

Both extract and reconstruct now carry an alias budget that caps the
number of nodes reached through alias expansion and the alias nesting
depth, throwing the parser's usual "YAML parse error" for documents that
exceed either limit.
…lect per file

addEntry/updateEntry now reject a source or target containing a tab,
carriage return or newline, naming the offending character. Passing argv
straight through let a single term shift every following column of the
uploaded dictionary.

tsvToEntries picks TSV or CSV once for the whole file instead of per line,
so a quoted CSV field containing a tab is no longer tab-split into garbage,
and rows whose parsed fields carry a separator character are skipped.
Commander keeps matching the parent command's options after a subcommand
name, so `deepl sync status --locale de` bound `de` to the parent `sync`
command and left the subcommand's own option store undefined. Both flags
are declared on the parent and on the subcommands, so the filter logic in
status/validate/export never received a value and --sync-config silently
fell back to the auto-detected config.

Add resolveSyncConfig() alongside the existing resolveLocale(), plus a
shared parseLocaleFilter(), and route status, validate, export, audit,
resolve, push, and pull through them.
…res by code

A client-side abort says nothing about whether the server already accepted
the request, yet every failure other than a 4xx was replayed for every HTTP
method. A slow batch translate or a large document upload that exceeded the
30s timeout was re-sent three more times, each attempt already accepted and
billed server-side; worst case, admin key creation duplicated keys whose
secret is returned once.

Automatic retries are now limited to idempotent methods (GET/HEAD/PUT/
DELETE/OPTIONS/TRACE). A POST is replayed only on transport errors that
prove it never reached the server (ECONNREFUSED/ENOTFOUND/EAI_AGAIN); 429
handling is unchanged for all methods because a rate-limited request was
rejected without being processed.

Classification now branches on the axios error code and the absence of a
response instead of matching message substrings: a client-side timeout
reads "timeout of 30000ms exceeded", matched no substring, and surfaced as
ValidationError (exit 6) where exit-codes.ts documents a network error
(exit 5). ECONNABORTED and ERR_CANCELED were absent entirely. 401 now maps
to AuthError (exit 2) instead of falling through to exit 6.

Also adds a wall-clock budget shared across attempts (default: twice the
per-request timeout, backoff sleeps excluded) so a never-responding server
bounds total wait instead of running the full attempt count, scopes the
Trace ID quoted in an error to the request that failed rather than the
client's last-seen header, and stops doubling the "Network error:" prefix.

Tests updated deliberately, all of which assumed retry-everything:
- deepl-client.test.ts retry-logic and maxRetries cases now exercise an
  idempotent GET; a new case asserts translate is not replayed.
- deepl-client.integration.test.ts 500-retry case likewise, plus a new
  no-replay assertion.
- sync.integration.test.ts 503 recovery now asserts the POST is not
  replayed and the locale is recorded as failed.
docs/API.md has documented that a locale outside target_locales passed to
`sync --locale` exits with a ConfigError, but the run reported success while
translating nothing — a typo'd locale made CI report green.

Validate the filter in applyCliOverrides, which every config load already
funnels through, so the root command and every subcommand that resolves a
--locale filter enforce it from one place. The error names the offending
locales and the configured ones.
…nc audit

`deepl sync audit` fell back to the lock file's per-locale hash when a target
file could not be read. That hash is a hash of the SOURCE text, so it is
identical across a term group by construction: divergent translations were
reported as consistent, and identical ones as an inconsistency displaying a
hex hash where a translation should be.

Unreadable targets are now excluded from the comparison and listed in a new
`missingTargets` field, which the text output prints as well.
`sync init` registered --sync-config but always resolved the config against
process.cwd(), so the flag could neither redirect where the file was written
nor which existing file triggered the already-exists guard.

Resolve the flag to the config file path (any basename, matching what
loadSyncConfig accepts) and use its directory as the root that detection and
the generated globs are relative to. configExists() and writeSyncConfig()
now take that path instead of a root directory.
… timeouts

The result endpoint is effectively single-use, yet a client-side timeout
replayed the download three more times; a replay against an exhausted
result can permanently lose an already-billed translation. Downloads now
run with retries disabled.

Uploads and downloads also move whole files, so both now get a 300s
transfer timeout instead of the 30s interactive request timeout (an
explicitly configured longer timeout still wins). Status polling keeps the
default timeout.
…ody size

Build request URLs through the URL API so a trailing slash on server: no
longer yields "//", a base path survives, and a query string or fragment is
rejected up front instead of silently truncating the API path.

Timeouts now throw TmsTimeoutError, a NetworkError subclass, so a hung TMS
exits 5 instead of falling through the untyped-message classifier to 1.

Error messages route the server URL through sanitizeUrl, which redacts
embedded credentials; the helper moved to src/utils/sanitize-url.ts and is
still re-exported from http-client.

pullKeys reads the body under a 32MiB cap — the existing key and value limits
only applied after the whole response had already been parsed.
writeSyncConfig used a plain writeFile, so an interrupted `deepl sync init`
could leave a truncated config in place of a working one. Use the same
atomic write-then-rename helper as the rest of the sync writers.
…ve-up

WebSocket reconnect was unreachable for real transport failures. The client
bridged the socket's 'error' event into callbacks.onError, which marked the
stream ended and rejected, so the close event that always follows an error
returned early and never reconnected — despite --reconnect defaulting on
with three attempts. Every socket also carried two 'error' listeners, so
the run promise was rejected twice.

The socket error is now recorded and handleClose decides whether to
reconnect, reporting the recorded transport error when no attempt is left.
VoiceClient no longer routes transport errors into the server-message
callbacks, leaving exactly one 'error' listener per socket and keeping
protocol errors distinguishable from dropped connections.

A terminal failure now also wakes the chunk pump and closes the input
generator. Previously a failed reconnect left a library consumer's audio
generator suspended at a yield forever, holding its fd.

Test updates: the two existing "WebSocket emits error" cases now emit the
close event that ws always follows an error with, and the voice-client case
asserts the socket owner sees transport errors instead of onError.
Both parsers matched elements with `<tag ...>([\s\S]*?)</tag>`, so every
opening tag with no matching close scanned to end of input — quadratic in
file size, and reachable with a file well under the sync size cap. A 4 MiB
input took minutes; Android reconstruct additionally rescanned the whole
file once per dotted key to classify string-array members.

Replace the lazy patterns with a single forward scan: sticky opening-tag
patterns that cannot match past the following tag, and one cursor for the
closing-tag search. A missing closing tag now ends the scan instead of
being retried per opening tag.

The scan also skips CDATA sections when looking for a closing tag, so
markup quoted inside one no longer truncates the element, and a
self-closing element is left alone rather than swallowing its successor.
… init

BREAKING CHANGE: `deepl sync init --source-lang` and `--target-langs` are
removed and now fail with "unknown option" (exit 1). Use --source-locale and
--target-locales instead.

The aliases were introduced in 1.x with a stderr deprecation warning naming
the replacement. The documented policy is that a 1.x alias is removed at the
2.0 cut, and this build is that cut, so keeping them would contradict the
shipped policy.

Drops the two hidden option registrations, applyDeprecationAliases() and its
warning constants, and the sourceLang/targetLangs fields on InitOptions. The
commander-tree snapshot is updated intentionally: the two options are gone
from the `init` subcommand.
Both the request timeout and the retry count were hardcoded with no flag or
config key, so a user whose 50-item batch or 30MB upload needed more than
30s had no way to say so. Both are now global options, validated as
integers (>= 1 ms and >= 0 retries) and wired into every client the commands
construct.

Adds an E2E suite that drives the CLI against a server which accepts
requests and never answers: it asserts exit code 5, that the translate POST
reaches the server exactly once, and that --timeout bounds the run.
# Conflicts:
#	src/sync/sync-init.ts
#	tests/unit/sync/sync-init.test.ts
Adds entries for the cache toggle persistence, YAML alias guard, git hooks
and glossary batch, sync option routing and locale validation, the sync init
alias removal, and the linear-time Android XML / XLIFF parsers. Also merges
the duplicated Fixed/Security headers in Unreleased into one section per
category, in canonical Keep a Changelog order.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Clients that wrap their own catch in handleError (write-client,
document-client) hand back errors classifyError already produced. Re-running
classification on those now returns them unchanged instead of re-deriving a
class from the wrapped message, which turned WriteClient's 400 ValidationError
into a NetworkError and dropped its style/tone docs hint.

Also tightens the total-deadline assertions to prove retries happen before
the budget cuts them off, rather than only that the count stays low.
…or note

Moves the network-prefix helper below computeBackoffWithJitter so that
function keeps its own doc comment, and states VoiceClient's transport-error
contract in terms of current behavior.
…last

nock v14 emits async socket errors from replyWithError that leak into later
tests, which the repo already works around by keeping those blocks last;
interleaved they segfaulted the jest worker on a full-suite run. Also aborts
pending requests before cleaning interceptors, matching the global teardown.
A 429 proves the server received and rejected the request without
processing it, so the idempotency restriction must not apply: the retry
stays enabled for POST and the Retry-After delay is respected verbatim.
sjsyrek and others added 4 commits July 29, 2026 14:26
…client fix batch

The README retry section now matches the shipped transport behavior:
idempotent-only automatic retry, POST replay restricted to provably-unsent
requests, 429 retried for all methods with Retry-After, full-jitter backoff
instead of the fixed ladder the section invented, and the new --timeout /
--max-retries global flags. CHANGELOG gains the Unreleased entries for the
retry, classification, deadline, document-download, voice-reconnect, and
watch-stats fixes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
…sport policy

TROUBLESHOOTING no longer claims universal 5xx auto-retry or the fixed
backoff ladder; API.md documents the shared wall-clock retry budget next to
the global flags. The changelog drops the watch filesWatched entry (that
counter was deliberately left for a follow-up issue, not fixed in this
batch) and gains the trace-ID cross-quoting and idempotent-classification
fixes that shipped with it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@sjsyrek
sjsyrek merged commit 59c4e4a into main Jul 29, 2026
5 checks passed
@sjsyrek
sjsyrek deleted the fix/release-blockers-batch branch July 29, 2026 18:43
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.

1 participant