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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

- Reject duplicate JSON object members and flattened-key collisions before catalog content can be lost. These integrity checks apply with or without `--strict`, including LLM translation responses and catalog rewrite operations.
- Report malformed catalogs during discovery and configuration checks. JSON errors identify conflicting member paths without printing translation values.
- Refuse ambiguous existing JSON pseudolocale targets even with `--force`; repair the catalog before replacing it.

## 0.2.0 - 2026-09-04

### Added
Expand Down
19 changes: 15 additions & 4 deletions cmd/internationalizer/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"strings"

"github.com/Tom-R-Main/Internationalizer/internal/config"
"github.com/Tom-R-Main/Internationalizer/internal/jsonintegrity"
localeid "github.com/Tom-R-Main/Internationalizer/internal/locale"
"github.com/spf13/cobra"
)
Expand All @@ -18,9 +19,10 @@ type recoveryAction struct {
}

type jsonFailure struct {
Code string `json:"code"`
Message string `json:"message"`
Recovery []recoveryAction `json:"recovery"`
Code string `json:"code"`
Message string `json:"message"`
Recovery []recoveryAction `json:"recovery"`
Details map[string]string `json:"details,omitempty"`
}

type jsonEnvelope struct {
Expand Down Expand Up @@ -75,7 +77,14 @@ func emitJSON(cmd *cobra.Command, status string, data any, err error) error {
if errors.Is(err, errValidationFailed) {
code = "validation_failed"
}
envelope.Errors = append(envelope.Errors, jsonFailure{Code: code, Message: safeErrorMessage(err), Recovery: []recoveryAction{errorRecovery(cmd, code)}})
failure := jsonFailure{Code: code, Message: safeErrorMessage(err), Recovery: []recoveryAction{errorRecovery(cmd, code)}}
var integrity *jsonintegrity.Error
if errors.As(err, &integrity) {
failure.Code = integrity.JSONCode()
failure.Recovery = []recoveryAction{errorRecovery(cmd, failure.Code)}
failure.Details = map[string]string{"key": integrity.Key, "path": integrity.Path, "other_path": integrity.OtherPath}
}
envelope.Errors = append(envelope.Errors, failure)
}
enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")
Expand All @@ -92,6 +101,8 @@ func errorRecovery(cmd *cobra.Command, code string) recoveryAction {
action := recoveryAction{Argv: []string{"internationalizer", "config", "check", "--json"}, SideEffects: []string{}, RequiredDecisions: []string{}}
withConfig := true
switch strings.ToLower(code) {
case "json_duplicate_member", "json_flattened_key_collision":
action.RequiredDecisions = []string{"repair_catalog_structure_without_discarding_values"}
case "invalid_arguments", "invalid_plan":
action.Argv = []string{"internationalizer", "commands", "--json"}
withConfig = false
Expand Down
42 changes: 42 additions & 0 deletions cmd/internationalizer/json_integrity_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package main

import (
"bytes"
"encoding/json"
"fmt"
"testing"

"github.com/Tom-R-Main/Internationalizer/internal/jsonintegrity"
)

func TestIntegrityErrorJSONIncludesLocations(t *testing.T) {
_, err := jsonintegrity.Decode([]byte(`{"a.b":"PRIVATE","a":{"b":"OTHER PRIVATE"}}`))
if err == nil {
t.Fatal("ambiguous input accepted")
}
cmd := newValidateCmd()
var out bytes.Buffer
cmd.SetOut(&out)
if emitJSON(cmd, "error", nil, fmt.Errorf("parsing source catalog.json: %w", err)) == nil {
t.Fatal("failure swallowed")
}
var envelope struct {
Errors []struct {
Code string
Details map[string]string
}
}
if err := json.Unmarshal(out.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
if len(envelope.Errors) != 1 || envelope.Errors[0].Code != "json_flattened_key_collision" {
t.Fatalf("output = %s", out.String())
}
details := envelope.Errors[0].Details
if details["path"] != "/a/b" || details["other_path"] != "/a.b" || details["key"] != "a.b" {
t.Fatalf("details = %+v", details)
}
if bytes.Contains(out.Bytes(), []byte("PRIVATE")) {
t.Fatal("catalog value leaked")
}
}
29 changes: 29 additions & 0 deletions docs/cli-onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,35 @@ silently reinterpreted as plain text.

## JSON, filtering, and failures

### Catalog integrity

JSON catalogs must have unique object members and unambiguous flattened keys.
For example, `{"a.b":"First","a":{"b":"Second"}}` gives two values the
same catalog identity, `a.b`, and is rejected. Duplicate members are rejected
even when their values match or their names use different JSON escapes.
These checks apply without `--strict`; sorting keys or choosing the last value
would discard content, not repair it.

Errors use `json_duplicate_member` or `json_flattened_key_collision`. Source
errors include member locations under `errors[].details`; target validation
findings include `path` and `other_path`. Locations are JSON pointers into the
catalog; the containing error or report identifies the catalog file. Resolve
the conflicting members explicitly, preserving the intended messages and
placeholders. Internationalizer does not choose a surviving value for you.

Discovery keeps malformed catalog candidates visible with `parse_error_code`.
`detect` remains advisory; `config check` fails when an error diagnostic exists,
even if presentation filters hide it. The same integrity checks protect LLM
response parsing and JSON catalog rewrites. `pseudo --force` bypasses ownership
checks, not JSON integrity checks.

Translation jobs expose a structured `input_error` when a target catalog or
provider response fails JSON integrity checks. The run can still report
`translation_failed` because other jobs may have completed; inspect the job's
error code and persistence flags before retrying.

### Output envelope

The onboarding commands, `commands`, `translate`, and `validate` use this envelope
when `--json` is supplied:

Expand Down
130 changes: 113 additions & 17 deletions internal/formats/json.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"sort"
"strconv"
"strings"

"github.com/Tom-R-Main/Internationalizer/internal/jsonintegrity"
)

type JSONFormat struct{}
Expand All @@ -15,8 +17,8 @@ func (f *JSONFormat) Name() string { return "json" }
func (f *JSONFormat) Extensions() []string { return []string{".json"} }

func (f *JSONFormat) Parse(data []byte) (map[string]string, error) {
var raw interface{}
if err := json.Unmarshal(data, &raw); err != nil {
raw, err := jsonintegrity.Decode(data)
if err != nil {
return nil, fmt.Errorf("json parse: %w", err)
}
result := make(map[string]string)
Expand Down Expand Up @@ -54,10 +56,8 @@ func (f *JSONFormat) Serialize(entries map[string]string, original []byte) ([]by
}

func (f *JSONFormat) RemoveEntries(original []byte, keys map[string]struct{}) ([]byte, error) {
var raw interface{}
dec := json.NewDecoder(bytes.NewReader(original))
dec.UseNumber()
if err := dec.Decode(&raw); err != nil {
raw, err := jsonintegrity.Decode(original)
if err != nil {
return nil, fmt.Errorf("json parse original: %w", err)
}
removeJSONEntries("", raw, keys)
Expand All @@ -68,6 +68,9 @@ func (f *JSONFormat) RemoveEntries(original []byte, keys map[string]struct{}) ([
if err := enc.Encode(raw); err != nil {
return nil, err
}
if _, err := jsonintegrity.Decode(buf.Bytes()); err != nil {
return nil, err
}
return bytes.TrimRight(buf.Bytes(), "\n"), nil
}

Expand All @@ -80,8 +83,10 @@ func removeJSONEntries(prefix string, value interface{}, keys map[string]struct{
path = prefix + "." + key
}
if _, remove := keys[path]; remove {
delete(node, key)
continue
if _, isString := child.(string); isString {
delete(node, key)
continue
}
}
removeJSONEntries(path, child, keys)
}
Expand All @@ -96,12 +101,13 @@ func removeJSONEntries(prefix string, value interface{}, keys map[string]struct{
// serializePreservingOrder walks the original JSON structure and replaces
// leaf values from the entries map, preserving key ordering.
func serializePreservingOrder(entries map[string]string, original []byte) ([]byte, error) {
var raw interface{}
dec := json.NewDecoder(bytes.NewReader(original))
dec.UseNumber()
if err := dec.Decode(&raw); err != nil {
raw, err := jsonintegrity.Decode(original)
if err != nil {
return nil, fmt.Errorf("json parse original: %w", err)
}
if raw == nil && len(entries) > 0 {
return nil, fmt.Errorf("json insertion would discard root null metadata")
}
replaced := make(map[string]struct{}, len(entries))
if _, rootString := raw.(string); rootString {
if replacement, ok := entries[""]; ok {
Expand All @@ -110,11 +116,24 @@ func serializePreservingOrder(entries map[string]string, original []byte) ([]byt
}
}
replaceLeaves("", raw, entries, replaced)
for key, value := range entries {
keys := make([]string, 0, len(entries))
for key := range entries {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool { return jsonPathLess(keys[i], keys[j]) })
for _, key := range keys {
if _, ok := replaced[key]; ok {
continue
}
if err := setPath(&raw, strings.Split(key, "."), value); err != nil {
if strings.Count(key, ".") >= jsonintegrity.MaxDepth {
return nil, &jsonintegrity.Error{Code: "json_nesting_limit"}
}
parts := strings.Split(key, ".")
// Root arrays use the same leading-dot identities emitted by flatten.
if _, rootArray := raw.([]interface{}); rootArray && parts[0] == "" {
parts = parts[1:]
}
if err := setPath(&raw, parts, entries[key]); err != nil {
return nil, fmt.Errorf("json set path %q: %w", key, err)
}
}
Expand All @@ -125,10 +144,51 @@ func serializePreservingOrder(entries map[string]string, original []byte) ([]byt
if err := enc.Encode(raw); err != nil {
return nil, err
}
if err := checkSerializedEntries(buf.Bytes(), entries); err != nil {
return nil, err
}
// json.Encoder adds a trailing newline; trim then add exactly one.
return bytes.TrimRight(buf.Bytes(), "\n"), nil
}

// jsonPathLess orders canonical array-index segments numerically so contiguous
// appends do not encounter index 10 before index 2. Numeric and other segments
// have separate ranks; mixing numeric pair comparisons with lexical fallback
// would violate transitivity (for example, "2", "10", and "1x").
// This changes insertion order, not how existing objects or dotted keys resolve.
func jsonPathLess(left, right string) bool {
lparts, rparts := strings.Split(left, "."), strings.Split(right, ".")
for i := 0; i < len(lparts) && i < len(rparts); i++ {
lpart, rpart := lparts[i], rparts[i]
if lpart == rpart {
continue
}
lnumeric, rnumeric := canonicalJSONIndex(lpart), canonicalJSONIndex(rpart)
if lnumeric != rnumeric {
return lnumeric
}
// Decimal length comparison avoids machine-integer overflow. Equal
// length canonical decimal indices have lexical numeric ordering.
if lnumeric && len(lpart) != len(rpart) {
return len(lpart) < len(rpart)
}
return lpart < rpart
}
return len(lparts) < len(rparts)
}

func canonicalJSONIndex(segment string) bool {
if segment == "" || (len(segment) > 1 && segment[0] == '0') {
return false
}
for _, c := range segment {
if c < '0' || c > '9' {
return false
}
}
return true
}

func replaceLeaves(prefix string, val interface{}, entries map[string]string, replaced map[string]struct{}) {
switch v := val.(type) {
case map[string]interface{}:
Expand All @@ -140,7 +200,7 @@ func replaceLeaves(prefix string, val interface{}, entries map[string]string, re
switch child.(type) {
case map[string]interface{}, []interface{}:
replaceLeaves(p, child, entries, replaced)
default:
case string:
if replacement, ok := entries[p]; ok {
v[key] = replacement
replaced[p] = struct{}{}
Expand All @@ -153,7 +213,7 @@ func replaceLeaves(prefix string, val interface{}, entries map[string]string, re
switch child.(type) {
case map[string]interface{}, []interface{}:
replaceLeaves(p, child, entries, replaced)
default:
case string:
if replacement, ok := entries[p]; ok {
v[i] = replacement
replaced[p] = struct{}{}
Expand All @@ -165,12 +225,21 @@ func replaceLeaves(prefix string, val interface{}, entries map[string]string, re

func setPath(target *interface{}, parts []string, value string) error {
if len(parts) == 0 {
if *target != nil {
return fmt.Errorf("replacement would discard existing content")
}
*target = value
return nil
}

if idx, err := strconv.Atoi(parts[0]); err == nil {
if arr, ok := (*target).([]interface{}); ok {
if idx < 0 || idx > len(arr) {
return fmt.Errorf("array index %q is outside existing structure", parts[0])
}
if idx < len(arr) && (len(parts) == 1 || arr[idx] == nil) {
return fmt.Errorf("replacement would discard existing array content")
}
if len(arr) <= idx {
expanded := make([]interface{}, idx+1)
copy(expanded, arr)
Expand Down Expand Up @@ -198,7 +267,10 @@ func setPath(target *interface{}, parts []string, value string) error {
default:
return fmt.Errorf("object segment %q conflicts with existing array or scalar", parts[0])
}
child := obj[parts[0]]
child, exists := obj[parts[0]]
if exists && (len(parts) == 1 || child == nil) {
return fmt.Errorf("replacement would discard existing content")
}
if err := setPath(&child, parts[1:], value); err != nil {
return err
}
Expand All @@ -217,6 +289,9 @@ func serializeFromScratch(entries map[string]string) ([]byte, error) {
sort.Strings(keys)

for _, key := range keys {
if strings.Count(key, ".") >= jsonintegrity.MaxDepth {
return nil, &jsonintegrity.Error{Code: "json_nesting_limit"}
}
if err := setPath(&root, strings.Split(key, "."), entries[key]); err != nil {
return nil, fmt.Errorf("json set path %q: %w", key, err)
}
Expand All @@ -229,5 +304,26 @@ func serializeFromScratch(entries map[string]string) ([]byte, error) {
if err := enc.Encode(root); err != nil {
return nil, err
}
if err := checkSerializedEntries(buf.Bytes(), entries); err != nil {
return nil, err
}
return bytes.TrimRight(buf.Bytes(), "\n"), nil
}

func checkSerializedEntries(data []byte, entries map[string]string) error {
parsed, err := (&JSONFormat{}).Parse(data)
if err != nil {
return err
}
keys := make([]string, 0, len(entries))
for key := range entries {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if value, exists := parsed[key]; !exists || value != entries[key] {
return fmt.Errorf("json set path %q cannot preserve the requested identity", key)
}
}
return nil
}
Loading