From 455f55d5fce8181b855dd5612d10f9a3b2a2fbf9 Mon Sep 17 00:00:00 2001 From: JanKolo04 Date: Tue, 11 Aug 2026 17:00:06 +0200 Subject: [PATCH] chore(i18n): document and script the lang-file merge resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backend/lang/{en,pl}.json conflict on almost every merge: new keys are appended, so every branch that adds a string edits the same final line. Eight open branches are currently doing that. The instinctive fix — end every entry with a comma so the last line is not special — is impossible: these are strict JSON files read by json_decode, and a trailing comma makes the file unparseable, which would silently drop the whole Polish UI back to English. So this documents the resolution instead, and automates it. .claude/skills/i18n-lang-files/ carries the rules and a resolver that merges git's three stages per key rather than per line. That is what makes it correct where a text-level union is not: - a key deliberately deleted on one side is not resurrected by the other side still having it (PR #239 removed two PAWS keys a naive union would restore), - a key added on both sides appears once; duplicated, json_decode keeps only the last and a translation vanishes with no error. It checks en/pl parity and duplicate keys, and refuses to guess when both sides changed the same translation's value differently. Verified by replaying the PR #239 conflict: byte-identical to the hand-made resolution. .claude/ stays ignored for personal assistant config; only .claude/skills/ is tracked, since these are shared project workflows. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/skills/i18n-lang-files/SKILL.md | 90 +++++++++ .../i18n-lang-files/resolve-lang-conflict.py | 183 ++++++++++++++++++ .gitignore | 7 +- CLAUDE.md | 2 +- 4 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/i18n-lang-files/SKILL.md create mode 100755 .claude/skills/i18n-lang-files/resolve-lang-conflict.py diff --git a/.claude/skills/i18n-lang-files/SKILL.md b/.claude/skills/i18n-lang-files/SKILL.md new file mode 100644 index 00000000..7a044ff8 --- /dev/null +++ b/.claude/skills/i18n-lang-files/SKILL.md @@ -0,0 +1,90 @@ +--- +name: i18n-lang-files +description: Editing or merging backend/lang/en.json and pl.json. Use when adding UI strings, when a merge conflicts in the lang files, or when deciding where a translation belongs. Covers why these files conflict on every merge and how to resolve one without silently resurrecting a deleted key or breaking en/pl parity. +--- + +# Translation files + +`backend/lang/en.json` and `backend/lang/pl.json` are the app's UI strings, keyed by +the English sentence. CLAUDE.md hard rule 2 applies: **both files always carry the +same key set**, and in `en.json` the value equals the key. + +## Trailing commas are not an option + +The first instinct on seeing these conflicts is "make every line end with a comma so +the last line is never special". **That cannot work.** These are strict `.json` files +read by `json_decode`; a trailing comma before `}` is a parse error, and Laravel would +fall back to returning every key as its own literal — the entire Polish UI would +silently revert to English. There is no punctuation fix. Do not add one, and do not +convert these to `.jsonc`, `.php` arrays or anything else without a deliberate +migration. + +## Why every merge conflicts + +New keys are appended to the end of the file. Every branch that adds a string touches +the same final line — the one whose comma has to change — so any two such branches +conflict by construction. At the last count, eight open branches were appending to +`en.json`. + +Two ways to reduce it, in order of cost: + +1. **Append in one block, at the end, in one commit.** Cheap and immediate. It does + not prevent the conflict but keeps it to a single contiguous hunk that the script + below resolves in one shot. +2. **Sort the files alphabetically, once.** This is the real fix: insertions scatter + through the file instead of piling onto the last line, so most branches stop + overlapping at all. The cost is a one-time full-file rewrite that conflicts with + every branch currently in flight, so it needs to be scheduled when the branch queue + is short — it is a team decision, not something to do mid-feature. + +## Resolving a conflict + +Never resolve these by hand-editing the `<<<<<<<` markers, and never take a plain +"union of both sides". A text-level union gets two cases wrong: + +- a key **deliberately deleted** on one side is silently resurrected by the other side + still having it (this really happened: PR #239 removed two `PAWS` keys that a naive + union would have put straight back), +- a key **added on both sides** ends up duplicated, and `json_decode` keeps only the + last one — so a translation disappears without any error. + +Use the script instead. It merges git's three stages per key rather than per line: + +```bash +# from the repository root, while the merge is conflicted +python3 .claude/skills/i18n-lang-files/resolve-lang-conflict.py --check # dry run +python3 .claude/skills/i18n-lang-files/resolve-lang-conflict.py # write +git add backend/lang/*.json +``` + +It reports what it did, keeps intentional deletions deleted, checks en/pl parity and +duplicate keys, and **refuses to guess** when both sides changed the same +translation's value differently — those it prints for a human to settle. + +## Adding a string + +- Add the key to **both** files in the same commit. `en.json`: value = key. +- Check it does not already exist — `grep -n '"Your string"' backend/lang/en.json` + before adding, since a near-duplicate wording is worse than a reused key. +- Placeholders are `:name`, e.g. `"Cancel work order :order?"`. +- Strings belonging to an optional module do **not** go here. A module ships its own + namespaced translations (`lang/{en,pl}/messages.php` + `loadTranslationsFrom`, + referenced as `mymodule::messages.key`), so core carries no vendor vocabulary. See + the Pantheon connector for the pattern. + +## Verifying + +```bash +python3 - <<'PY' +import json +en = json.load(open('backend/lang/en.json', encoding='utf-8')) +pl = json.load(open('backend/lang/pl.json', encoding='utf-8')) +print('en', len(en), 'pl', len(pl)) +print('missing from pl:', sorted(set(en) - set(pl))[:10]) +print('missing from en:', sorted(set(pl) - set(en))[:10]) +print('untranslated:', [k for k, v in pl.items() if v == k][:10]) +PY +``` + +Both files must parse, the key sets must match, and a `pl.json` value equal to its key +means the string was added but never translated. diff --git a/.claude/skills/i18n-lang-files/resolve-lang-conflict.py b/.claude/skills/i18n-lang-files/resolve-lang-conflict.py new file mode 100755 index 00000000..7176c15e --- /dev/null +++ b/.claude/skills/i18n-lang-files/resolve-lang-conflict.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Resolve a merge conflict in backend/lang/*.json. + +Works on git's three stages rather than on the <<<<<<< markers in the working +tree. Each stage is a complete, valid JSON file, so the merge can be done per +key instead of per line — which is the only way to get the two cases that a +line-level union gets wrong: + + * a key deleted on our side and still present on theirs stays deleted + (a text union silently resurrects it), + * a key added on both sides appears once, not twice. + +A key whose value both sides changed differently is never guessed at: it is +reported and left for a human. + +Usage: + python3 resolve-lang-conflict.py [--check] [paths...] + + no paths every conflicted backend/lang/*.json + --check report what would happen, write nothing +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from collections import Counter, OrderedDict + +STAGES = {"base": 1, "ours": 2, "theirs": 3} + + +def git(*args: str) -> str: + return subprocess.run( + ["git", *args], capture_output=True, text=True, check=True + ).stdout + + +def conflicted_lang_files() -> list[str]: + out = git("diff", "--name-only", "--diff-filter=U") + return [ + p + for p in out.splitlines() + if p.startswith("backend/lang/") and p.endswith(".json") + ] + + +def stage(path: str, n: int, label: str) -> "OrderedDict[str, str]": + """One side of the conflict, as an ordered key -> value map. + + Duplicates are caught here, because this is the only point they are still + visible: json.loads keeps the last occurrence and drops the rest silently, + so a file with a doubled key parses fine and quietly loses a translation. + """ + raw = git("show", f":{n}:{path}") + pairs: list[tuple[str, str]] = json.loads(raw, object_pairs_hook=list) + + dupes = [k for k, c in Counter(k for k, _ in pairs).items() if c > 1] + if dupes: + print(f" !! {path} ({label}) has duplicate key(s): {dupes[:5]}") + + return OrderedDict(pairs) + + +def merge(base, ours, theirs) -> tuple["OrderedDict[str, str]", list[str], dict]: + deleted_by_us = set(base) - set(ours) + deleted_by_them = set(base) - set(theirs) + + merged: "OrderedDict[str, str]" = OrderedDict() + conflicts: list[str] = [] + + def value_for(k: str): + in_ours, in_theirs = k in ours, k in theirs + + if in_ours and in_theirs: + if ours[k] == theirs[k]: + return ours[k] + # Both sides changed the same translation. Prefer the side that + # actually changed it relative to base; if both did, ask a human. + if base.get(k) == theirs[k]: + return ours[k] + if base.get(k) == ours[k]: + return theirs[k] + conflicts.append(k) + return ours[k] + + return ours[k] if in_ours else theirs[k] + + # Our order first, so an existing file keeps its shape and the diff stays + # readable; then whatever the other side added, in their order. + for k in ours: + if k in deleted_by_them: + continue + merged[k] = value_for(k) + + for k in theirs: + if k in merged or k in deleted_by_us: + continue + merged[k] = value_for(k) + + stats = { + "kept_deleted_by_us": sorted(deleted_by_us & set(theirs)), + "kept_deleted_by_them": sorted(deleted_by_them & set(ours)), + "added_by_them": [k for k in theirs if k not in ours and k not in deleted_by_us], + } + return merged, conflicts, stats + + +def dump(mapping) -> str: + """Match the formatting Laravel's lang files already use: 4 spaces, unescaped + unicode, no trailing comma (JSON forbids one — that is why these conflicts + cannot be avoided by punctuation).""" + return json.dumps(mapping, ensure_ascii=False, indent=4) + "\n" + + +def main() -> int: + argv = [a for a in sys.argv[1:] if a != "--check"] + check_only = "--check" in sys.argv + + paths = argv or conflicted_lang_files() + if not paths: + print("No conflicted backend/lang/*.json files.") + return 0 + + failed = False + written: dict[str, "OrderedDict[str, str]"] = {} + + for path in paths: + try: + base = stage(path, STAGES["base"], "base") + ours = stage(path, STAGES["ours"], "ours") + theirs = stage(path, STAGES["theirs"], "theirs") + except subprocess.CalledProcessError: + print(f"{path}: not a conflicted file (no merge stages) — skipped") + continue + + merged, conflicts, stats = merge(base, ours, theirs) + + print(f"\n{path}") + print(f" base {len(base)} | ours {len(ours)} | theirs {len(theirs)} -> {len(merged)}") + if stats["added_by_them"]: + print(f" + {len(stats['added_by_them'])} new key(s) from the other side") + for k in stats["kept_deleted_by_us"]: + print(f" - stayed deleted (we removed it): {k[:70]}") + for k in stats["kept_deleted_by_them"]: + print(f" - stayed deleted (they removed it): {k[:70]}") + + if conflicts: + failed = True + print(f" !! {len(conflicts)} key(s) changed differently on both sides — resolve by hand:") + for k in conflicts: + print(f" {k[:70]}") + print(f" ours: {ours[k][:70]}") + print(f" theirs: {theirs[k][:70]}") + + written[path] = merged + if not check_only and not conflicts: + with open(path, "w", encoding="utf-8") as fh: + fh.write(dump(merged)) + + # en/pl must carry the same key set (CLAUDE.md hard rule 2). + en = next((m for p, m in written.items() if p.endswith("en.json")), None) + pl = next((m for p, m in written.items() if p.endswith("pl.json")), None) + if en is not None and pl is not None: + only_en, only_pl = sorted(set(en) - set(pl)), sorted(set(pl) - set(en)) + print(f"\nparity: en {len(en)} | pl {len(pl)}") + for k in only_en: + print(f" missing from pl.json: {k[:70]}") + for k in only_pl: + print(f" missing from en.json: {k[:70]}") + if only_en or only_pl: + failed = True + + if check_only: + print("\n--check: nothing written.") + elif not failed: + print("\nWritten. Stage them with: git add backend/lang/*.json") + + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.gitignore b/.gitignore index d00d353d..03b0fa06 100644 --- a/.gitignore +++ b/.gitignore @@ -40,7 +40,12 @@ backend/yarn-error.log .aiderrules .copilot-instructions.md .github/copilot-instructions.md -.claude/ +# Personal AI-assistant config stays out of the repo, but skills under +# .claude/skills/ are shared project workflows and are tracked. The parent is +# `.claude/*` rather than `.claude/`: git does not descend into an excluded +# directory, so a negation inside one would never be reached. +.claude/* +!.claude/skills/ .claude-flow/ .mcp.json diff --git a/CLAUDE.md b/CLAUDE.md index 3d82efdb..d0ef825b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d # dev ove ## Hard rules 1. **English-first** — all code, Blade/JSX text, validation messages, seeders, comments are English. Other languages exist only as translations in `backend/lang/*.json`. -2. **i18n parity** — `lang/en.json` and `lang/pl.json` must contain the same key set. Adding a UI string means adding the key to **both** files (English value = key itself in `en.json`). +2. **i18n parity** — `lang/en.json` and `lang/pl.json` must contain the same key set. Adding a UI string means adding the key to **both** files (English value = key itself in `en.json`). These are strict JSON: **no trailing comma** on the last entry, or `json_decode` fails and the whole UI silently falls back to English. New keys are appended, so the files conflict on almost every merge — resolve with `.claude/skills/i18n-lang-files/`, never by hand-editing the conflict markers (a text-level union resurrects deliberately deleted keys and duplicates others). 3. **Form Requests for validation** — never validate inline in controllers. Frontend validation is UX only; the backend rule set is authoritative. 4. **Never rename migration filenames after merge** — the filename is the migration's identity; renaming breaks every existing database on upgrade (duplicate-table crash in the entrypoint migrate). 5. **Tests are mandatory** for new endpoints/business logic: happy path, validation 422, authorization (guest + wrong role), domain edge cases. Use factories, `RefreshDatabase`, and follow `tests/Feature` / `tests/Unit` conventions.