Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions .claude/skills/i18n-lang-files/SKILL.md
Original file line number Diff line number Diff line change
@@ -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.
183 changes: 183 additions & 0 deletions .claude/skills/i18n-lang-files/resolve-lang-conflict.py
Original file line number Diff line number Diff line change
@@ -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())
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading