glotfile 0.8.2 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -690,7 +690,7 @@ function setSourceValue(state, key, value) {
690
690
  function setTargetValue(state, key, locale, value, clock = systemClock) {
691
691
  const entry = requireKey(state, key);
692
692
  if (entry.plural) throw new GlotfileError(`Key is a plural; use the plural setters: ${key}`);
693
- entry.values[locale] = { value: value.trim(), state: "reviewed", updatedAt: clock() };
693
+ entry.values[canonLocale(locale)] = { value: value.trim(), state: "reviewed", updatedAt: clock() };
694
694
  }
695
695
  function formSignature(forms) {
696
696
  return Object.entries(forms).sort(([a], [b]) => a.localeCompare(b)).map(([cat, val]) => `${cat}:${normalizeSource(val ?? "")}`).join("|");
@@ -713,8 +713,9 @@ function setSourcePluralForms(state, key, forms) {
713
713
  }
714
714
  function setPluralForms(state, key, locale, forms, clock = systemClock) {
715
715
  const entry = requirePlural(state, key);
716
- if (locale === state.config.sourceLocale) throw new GlotfileError("Use setSourcePluralForms for the source locale");
717
- entry.values[locale] = { forms: normalizeForms(forms), state: "reviewed", updatedAt: clock() };
716
+ const loc = canonLocale(locale);
717
+ if (loc === state.config.sourceLocale) throw new GlotfileError("Use setSourcePluralForms for the source locale");
718
+ entry.values[loc] = { forms: normalizeForms(forms), state: "reviewed", updatedAt: clock() };
718
719
  }
719
720
  function convertToPlural(state, key, arg) {
720
721
  const entry = requireKey(state, key);
@@ -742,12 +743,15 @@ function convertToScalar(state, key) {
742
743
  }
743
744
  function clearValue(state, key, locale) {
744
745
  const entry = requireKey(state, key);
745
- if (locale === state.config.sourceLocale) throw new GlotfileError("Cannot clear the source value");
746
- delete entry.values[locale];
746
+ const loc = canonLocale(locale);
747
+ if (loc === state.config.sourceLocale) throw new GlotfileError("Cannot clear the source value");
748
+ delete entry.values[loc];
747
749
  }
748
750
  function setKeyState(state, key, locale, next) {
751
+ if (!STATES.includes(next)) throw new GlotfileError(`Unknown translation state: ${next}`);
749
752
  const entry = requireKey(state, key);
750
- const lv = entry.values[locale];
753
+ const loc = canonLocale(locale);
754
+ const lv = entry.values[loc];
751
755
  if (!lv) throw new GlotfileError(`No value for ${key} @ ${locale}`);
752
756
  lv.state = next;
753
757
  }
@@ -820,14 +824,16 @@ function removeCustomWord(state, word) {
820
824
  function applyMachineTranslation(state, key, locale, value, clock = systemClock, force = false) {
821
825
  const entry = requireKey(state, key);
822
826
  if (entry.plural) throw new GlotfileError(`Key is a plural; use applyMachineTranslationForms: ${key}`);
823
- if (!force && entry.values[locale]?.state === "reviewed") return false;
824
- entry.values[locale] = { value: value.trim(), state: "machine", source: "ai", updatedAt: clock() };
827
+ const loc = canonLocale(locale);
828
+ if (!force && entry.values[loc]?.state === "reviewed") return false;
829
+ entry.values[loc] = { value: value.trim(), state: "machine", source: "ai", updatedAt: clock() };
825
830
  return true;
826
831
  }
827
832
  function applyMachineTranslationForms(state, key, locale, forms, clock = systemClock, force = false) {
828
833
  const entry = requirePlural(state, key);
829
- if (!force && entry.values[locale]?.state === "reviewed") return false;
830
- entry.values[locale] = { forms: normalizeForms(forms), state: "machine", source: "ai", updatedAt: clock() };
834
+ const loc = canonLocale(locale);
835
+ if (!force && entry.values[loc]?.state === "reviewed") return false;
836
+ entry.values[loc] = { forms: normalizeForms(forms), state: "machine", source: "ai", updatedAt: clock() };
831
837
  return true;
832
838
  }
833
839
  var systemClock;
@@ -1245,28 +1251,33 @@ var init_i18next_json = __esm({
1245
1251
  export(state, output) {
1246
1252
  const files = [];
1247
1253
  const warnings = [];
1248
- const fmt = state.config.format;
1254
+ const { indent, finalNewline } = resolveFormat(state, output);
1255
+ const fmt = { indent, sortKeys: true, finalNewline };
1256
+ const emptyAs = resolveEmptyAs(output, "omit");
1249
1257
  const collided = /* @__PURE__ */ new Set();
1250
1258
  warnings.push(...localeCollisionWarnings(output, state.config.locales, DEFAULT_LOCALE_CASE3));
1251
1259
  for (const locale of state.config.locales) {
1252
1260
  const obj = {};
1253
1261
  for (const [key, entry] of Object.entries(state.keys)) {
1254
- const lv = entry.values[locale];
1255
- if (!lv) continue;
1256
1262
  const segments = key.split(".");
1257
1263
  const leaf = segments[segments.length - 1];
1258
1264
  const parent = segments.slice(0, -1);
1259
1265
  if (entry.plural) {
1260
- if (!lv.forms) continue;
1266
+ const forms = resolveForms(entry, locale, state.config.sourceLocale, emptyAs);
1267
+ if (!forms) continue;
1261
1268
  for (const cat of PLURAL_CATEGORIES) {
1262
- const body = lv.forms[cat];
1269
+ const body = forms[cat];
1263
1270
  if (body === void 0) continue;
1264
1271
  if (setNested(obj, [...parent, `${leaf}_${cat}`], toI18next(body))) collided.add(key);
1265
1272
  }
1266
1273
  continue;
1267
1274
  }
1268
- if (lv.value === void 0) continue;
1269
- if (setNested(obj, segments, toI18next(lv.value))) collided.add(key);
1275
+ const raw = resolveScalar(entry, locale, state.config.sourceLocale, emptyAs);
1276
+ if (raw === null) continue;
1277
+ if (raw && isIcuPluralOrSelect(raw)) {
1278
+ warnings.push({ code: "lossy-plural", key, locale, message: "i18next-json does not yet convert ICU plural/select; written unconverted" });
1279
+ }
1280
+ if (setNested(obj, segments, toI18next(raw))) collided.add(key);
1270
1281
  }
1271
1282
  files.push({ path: resolvePath(output.path, resolveLocaleToken(output, locale, DEFAULT_LOCALE_CASE3)), contents: serializeJson(obj, fmt) });
1272
1283
  }
@@ -2465,6 +2476,7 @@ var init_anthropic = __esm({
2465
2476
  }, { signal });
2466
2477
  this.recordUsage(res.usage);
2467
2478
  const text = res.content.find((b) => b.type === "text")?.text ?? "";
2479
+ if (res.stop_reason === "max_tokens") throw new MalformedReplyError(text);
2468
2480
  return parseReplyItems(text);
2469
2481
  }
2470
2482
  };
@@ -2558,6 +2570,7 @@ var init_openai = __esm({
2558
2570
  ]
2559
2571
  }, { signal });
2560
2572
  const text = res.choices?.[0]?.message?.content ?? "";
2573
+ if (res.choices?.[0]?.finish_reason === "length") throw new MalformedReplyError(text);
2561
2574
  return parseReplyItems(text);
2562
2575
  }
2563
2576
  };
@@ -2675,8 +2688,11 @@ var init_bedrock = __esm({
2675
2688
  const res = await this.client.send(this.makeCommand(this.buildInput(batch)), { abortSignal: signal });
2676
2689
  const blocks = res.output?.message?.content ?? [];
2677
2690
  const tool = blocks.find((b) => b.toolUse)?.toolUse;
2678
- if (tool?.input?.items) return tool.input.items;
2679
2691
  const text = blocks.find((b) => b.text)?.text ?? "";
2692
+ if (res.stopReason === "max_tokens") {
2693
+ throw new MalformedReplyError(text || JSON.stringify(tool?.input ?? {}));
2694
+ }
2695
+ if (tool?.input?.items) return tool.input.items;
2680
2696
  return parseReplyItems(text);
2681
2697
  }
2682
2698
  };
@@ -3132,8 +3148,30 @@ function attachScreenshotsForProvider(reqs, state, projectRoot, supportsVision)
3132
3148
  const keys = new Set(reqs.filter((r) => state.keys[r.key]?.screenshot).map((r) => r.key));
3133
3149
  return { skipped: keys.size };
3134
3150
  }
3135
- async function runLocaleParallel(reqs, provider, hooks = {}, concurrency = DEFAULT_LOCALE_CONCURRENCY, signal, batchSize = Infinity) {
3151
+ function isTransientError(err) {
3152
+ const e = err;
3153
+ if (e && typeof e.status === "number") return e.status === 429 || e.status >= 500;
3154
+ const code = e?.code;
3155
+ return code === "ECONNRESET" || code === "ETIMEDOUT" || code === "ECONNREFUSED" || code === "EPIPE" || code === "EAI_AGAIN";
3156
+ }
3157
+ function sleep(ms, signal) {
3158
+ if (ms <= 0 || signal?.aborted) return Promise.resolve();
3159
+ return new Promise((resolve12) => {
3160
+ const t = setTimeout(() => {
3161
+ signal?.removeEventListener("abort", onAbort);
3162
+ resolve12();
3163
+ }, ms);
3164
+ const onAbort = () => {
3165
+ clearTimeout(t);
3166
+ resolve12();
3167
+ };
3168
+ signal?.addEventListener("abort", onAbort, { once: true });
3169
+ });
3170
+ }
3171
+ async function runLocaleParallel(reqs, provider, hooks = {}, concurrency = DEFAULT_LOCALE_CONCURRENCY, signal, batchSize = Infinity, retry = {}) {
3136
3172
  if (!reqs.length) return [];
3173
+ const maxRetries = retry.retries ?? 3;
3174
+ const delayMs = retry.delayMs ?? ((attempt) => 250 * 2 ** attempt);
3137
3175
  const byLocale = /* @__PURE__ */ new Map();
3138
3176
  for (const req of reqs) {
3139
3177
  let group = byLocale.get(req.targetLocale);
@@ -3168,10 +3206,20 @@ async function runLocaleParallel(reqs, provider, hooks = {}, concurrency = DEFAU
3168
3206
  started.add(locale);
3169
3207
  hooks.onLocaleStart?.(locale);
3170
3208
  }
3171
- const batchResults = await provider.translate(batch, (_localeDone, _localeTotal, results) => {
3172
- done += results.length;
3173
- hooks.onBatchComplete?.(done, total, results, locale);
3174
- }, signal, (raw, size) => hooks.onMalformedReply?.(raw, size, locale));
3209
+ let batchResults;
3210
+ for (let attempt = 0; ; attempt++) {
3211
+ try {
3212
+ batchResults = await provider.translate(batch, (_localeDone, _localeTotal, results) => {
3213
+ done += results.length;
3214
+ hooks.onBatchComplete?.(done, total, results, locale);
3215
+ }, signal, (raw, size) => hooks.onMalformedReply?.(raw, size, locale));
3216
+ break;
3217
+ } catch (err) {
3218
+ if (attempt >= maxRetries || signal?.aborted || !isTransientError(err)) throw err;
3219
+ hooks.onRetry?.(locale, attempt + 1, err);
3220
+ await sleep(delayMs(attempt), signal);
3221
+ }
3222
+ }
3175
3223
  allResults.push(...batchResults);
3176
3224
  const left = remaining.get(locale) - 1;
3177
3225
  remaining.set(locale, left);
@@ -3262,26 +3310,63 @@ var init_pending_batch = __esm({
3262
3310
  });
3263
3311
 
3264
3312
  // src/server/log.ts
3265
- import { appendFileSync, readFileSync as readFileSync7, existsSync as existsSync7 } from "fs";
3313
+ import { appendFileSync, existsSync as existsSync7, openSync, fstatSync, readSync, closeSync, statSync as statSync2, readFileSync as readFileSync7 } from "fs";
3266
3314
  import { resolve as resolve5 } from "path";
3267
3315
  function logPath(projectRoot) {
3268
3316
  return resolve5(projectRoot, ".glotfile", "log.jsonl");
3269
3317
  }
3270
3318
  function appendLog(projectRoot, entry) {
3271
3319
  ensureGlotfileDir(projectRoot);
3272
- appendFileSync(logPath(projectRoot), JSON.stringify(entry) + "\n", "utf8");
3273
- }
3274
- function readLog(projectRoot, limit = 100) {
3275
3320
  const path = logPath(projectRoot);
3276
- if (!existsSync7(path)) return [];
3321
+ appendFileSync(path, JSON.stringify(entry) + "\n", "utf8");
3322
+ trimLog(path);
3323
+ }
3324
+ function trimLog(path, maxBytes = MAX_LOG_BYTES, targetBytes = TRIM_LOG_TO_BYTES) {
3325
+ if (!existsSync7(path) || statSync2(path).size <= maxBytes) return;
3277
3326
  const lines = readFileSync7(path, "utf8").split("\n").filter((l) => l.trim() !== "");
3278
- const entries = lines.map((l) => JSON.parse(l));
3279
- return entries.reverse().slice(0, limit);
3327
+ const kept = [];
3328
+ let bytes = 0;
3329
+ for (let i = lines.length - 1; i >= 0; i--) {
3330
+ const lineBytes = Buffer.byteLength(lines[i], "utf8") + 1;
3331
+ if (kept.length > 0 && bytes + lineBytes > targetBytes) break;
3332
+ kept.unshift(lines[i]);
3333
+ bytes += lineBytes;
3334
+ }
3335
+ writeFileAtomic(path, kept.length ? kept.join("\n") + "\n" : "");
3336
+ }
3337
+ function readLastLines(path, n, chunkSize = 64 * 1024) {
3338
+ if (n <= 0 || !existsSync7(path)) return [];
3339
+ const fd = openSync(path, "r");
3340
+ try {
3341
+ let pos = fstatSync(fd).size;
3342
+ if (pos === 0) return [];
3343
+ const chunks = [];
3344
+ while (pos > 0) {
3345
+ const size = Math.min(chunkSize, pos);
3346
+ pos -= size;
3347
+ const buf = Buffer.alloc(size);
3348
+ readSync(fd, buf, 0, size, pos);
3349
+ chunks.unshift(buf);
3350
+ const segments = Buffer.concat(chunks).toString("utf8").split("\n");
3351
+ const complete = (pos > 0 ? segments.slice(1) : segments).filter((l) => l.trim() !== "");
3352
+ if (complete.length >= n || pos === 0) return complete.slice(-n);
3353
+ }
3354
+ return [];
3355
+ } finally {
3356
+ closeSync(fd);
3357
+ }
3280
3358
  }
3359
+ function readLog(projectRoot, limit = 100) {
3360
+ return readLastLines(logPath(projectRoot), limit).map((l) => JSON.parse(l)).reverse();
3361
+ }
3362
+ var MAX_LOG_BYTES, TRIM_LOG_TO_BYTES;
3281
3363
  var init_log = __esm({
3282
3364
  "src/server/log.ts"() {
3283
3365
  "use strict";
3284
3366
  init_glotfile_dir();
3367
+ init_atomic_write();
3368
+ MAX_LOG_BYTES = 5 * 1024 * 1024;
3369
+ TRIM_LOG_TO_BYTES = 4 * 1024 * 1024;
3285
3370
  }
3286
3371
  });
3287
3372
 
@@ -3873,7 +3958,7 @@ var init_scan = __esm({
3873
3958
  });
3874
3959
 
3875
3960
  // src/server/scanner.ts
3876
- import { readdirSync as readdirSync3, statSync as statSync2, readFileSync as readFileSync11 } from "fs";
3961
+ import { readdirSync as readdirSync3, statSync as statSync3, readFileSync as readFileSync11 } from "fs";
3877
3962
  import { join as join5, extname as extname2, relative } from "path";
3878
3963
  function scannerForExt(ext) {
3879
3964
  return EXT_SCANNER[ext] ?? null;
@@ -4030,7 +4115,7 @@ function* walkFiles(dir, root, exclude) {
4030
4115
  const rel = relative(root, abs);
4031
4116
  let st;
4032
4117
  try {
4033
- st = statSync2(abs);
4118
+ st = statSync3(abs);
4034
4119
  } catch {
4035
4120
  continue;
4036
4121
  }
@@ -4059,7 +4144,7 @@ function runScan(projectRoot, opts, existing) {
4059
4144
  const abs = join5(projectRoot, relPath);
4060
4145
  let st;
4061
4146
  try {
4062
- st = statSync2(abs);
4147
+ st = statSync3(abs);
4063
4148
  } catch {
4064
4149
  continue;
4065
4150
  }
@@ -4110,7 +4195,21 @@ var init_scanner = __esm({
4110
4195
  // t('key') — word boundary before t, not preceded by dot (excludes i18n.t which is above)
4111
4196
  /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*'([^']+)'/g,
4112
4197
  /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*"([^"]+)"/g,
4113
- /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*`([^`$\n]+)`/g
4198
+ /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*`([^`$\n]+)`/g,
4199
+ // vue-i18n pluralization: $tc('key') and the destructured bare tc('key').
4200
+ /\$tc\s*\(\s*'([^']+)'/g,
4201
+ /\$tc\s*\(\s*"([^"]+)"/g,
4202
+ /(?<!\.)(?<![a-zA-Z0-9_$])\btc\s*\(\s*'([^']+)'/g,
4203
+ /(?<!\.)(?<![a-zA-Z0-9_$])\btc\s*\(\s*"([^"]+)"/g,
4204
+ // React-i18next <Trans i18nKey="key" /> (attribute order tolerated).
4205
+ /<Trans\b[^>]*\bi18nKey\s*=\s*'([^']+)'/g,
4206
+ /<Trans\b[^>]*\bi18nKey\s*=\s*"([^"]+)"/g,
4207
+ // A renamed translate() wrapper (covers the common `const { t: translate }`
4208
+ // alias by name; arbitrary aliases aren't resolved). Method `.translate()`
4209
+ // is excluded. Over-matching here only keeps keys "used" — the safe direction
4210
+ // for prune, which deletes only keys with no match at all.
4211
+ /(?<!\.)(?<![a-zA-Z0-9_$])\btranslate\s*\(\s*'([^']+)'/g,
4212
+ /(?<!\.)(?<![a-zA-Z0-9_$])\btranslate\s*\(\s*"([^"]+)"/g
4114
4213
  ],
4115
4214
  gettext: [
4116
4215
  /\b(?:gettext|ngettext)\s*\(\s*'([^']+)'/g,
@@ -4144,7 +4243,7 @@ var init_scanner = __esm({
4144
4243
  /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*`([^`$]*)\$\{/g
4145
4244
  ]
4146
4245
  };
4147
- CACHE_VERSION = 6;
4246
+ CACHE_VERSION = 7;
4148
4247
  EXT_SCANNER = {
4149
4248
  ".php": "laravel",
4150
4249
  ".vue": "js-i18n",
@@ -4188,11 +4287,11 @@ var init_scanner = __esm({
4188
4287
  });
4189
4288
 
4190
4289
  // src/server/import/detect.ts
4191
- import { existsSync as existsSync11, readdirSync as readdirSync4, readFileSync as readFileSync12, statSync as statSync3 } from "fs";
4290
+ import { existsSync as existsSync11, readdirSync as readdirSync4, readFileSync as readFileSync12, statSync as statSync4 } from "fs";
4192
4291
  import { join as join6 } from "path";
4193
4292
  function safeIsDir(p) {
4194
4293
  try {
4195
- return statSync3(p).isDirectory();
4294
+ return statSync4(p).isDirectory();
4196
4295
  } catch {
4197
4296
  return false;
4198
4297
  }
@@ -4228,7 +4327,7 @@ function detectVue(root, forced = false) {
4228
4327
  if (enough) {
4229
4328
  const sourceLocale = pickSource(locales, (loc) => {
4230
4329
  try {
4231
- return statSync3(join6(localeRoot, `${loc}.json`)).size;
4330
+ return statSync4(join6(localeRoot, `${loc}.json`)).size;
4232
4331
  } catch {
4233
4332
  return 0;
4234
4333
  }
@@ -4265,7 +4364,7 @@ function detectApple(root) {
4265
4364
  locales,
4266
4365
  sourceLocale: pickSource(locales, (loc) => {
4267
4366
  try {
4268
- return statSync3(join6(dir, `${loc}.lproj`, "Localizable.strings")).size;
4367
+ return statSync4(join6(dir, `${loc}.lproj`, "Localizable.strings")).size;
4269
4368
  } catch {
4270
4369
  return 0;
4271
4370
  }
@@ -4325,7 +4424,7 @@ function detectI18next(root) {
4325
4424
  if (locales.length === 0) continue;
4326
4425
  const sourceLocale = pickSource(locales, (loc) => {
4327
4426
  try {
4328
- return readdirSync4(join6(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync3(join6(localeRoot, loc, f)).size, 0);
4427
+ return readdirSync4(join6(localeRoot, loc)).filter((f) => f.endsWith(".json")).reduce((sum, f) => sum + statSync4(join6(localeRoot, loc, f)).size, 0);
4329
4428
  } catch {
4330
4429
  return 0;
4331
4430
  }
@@ -4430,10 +4529,9 @@ var init_detect = __esm({
4430
4529
  function flattenObject(value, prefix, warnings) {
4431
4530
  const out = {};
4432
4531
  const walk = (node, path) => {
4433
- if (typeof node === "string") {
4434
- out[path] = node;
4435
- } else if (typeof node === "number" || typeof node === "boolean") {
4436
- out[path] = String(node);
4532
+ if (typeof node === "string" || typeof node === "number" || typeof node === "boolean") {
4533
+ if (path in out) warnings.push(`duplicate flattened key "${path}" \u2014 keeping the first value`);
4534
+ else out[path] = typeof node === "string" ? node : String(node);
4437
4535
  } else if (Array.isArray(node)) {
4438
4536
  node.forEach((el, i) => walk(el, path ? `${path}.${i}` : String(i)));
4439
4537
  } else if (node && typeof node === "object") {
@@ -4502,18 +4600,18 @@ var init_placeholders2 = __esm({
4502
4600
  });
4503
4601
 
4504
4602
  // src/server/import/parsers/laravel-php.ts
4505
- import { readdirSync as readdirSync6, statSync as statSync4 } from "fs";
4603
+ import { readdirSync as readdirSync6, statSync as statSync5 } from "fs";
4506
4604
  import { join as join8, relative as relative2 } from "path";
4507
4605
  import { execFileSync } from "child_process";
4508
4606
  function listDirs2(dir) {
4509
- return readdirSync6(dir).filter((e) => statSync4(join8(dir, e)).isDirectory());
4607
+ return readdirSync6(dir).filter((e) => statSync5(join8(dir, e)).isDirectory());
4510
4608
  }
4511
4609
  function listPhpFiles(dir) {
4512
4610
  const out = [];
4513
4611
  const walk = (d) => {
4514
4612
  for (const e of readdirSync6(d)) {
4515
4613
  const full = join8(d, e);
4516
- if (statSync4(full).isDirectory()) walk(full);
4614
+ if (statSync5(full).isDirectory()) walk(full);
4517
4615
  else if (e.endsWith(".php")) out.push(full);
4518
4616
  }
4519
4617
  };
@@ -4648,7 +4746,7 @@ var init_flutter_arb2 = __esm({
4648
4746
  });
4649
4747
 
4650
4748
  // src/server/import/parsers/apple-strings.ts
4651
- import { readdirSync as readdirSync8, readFileSync as readFileSync15, statSync as statSync5 } from "fs";
4749
+ import { readdirSync as readdirSync8, readFileSync as readFileSync15, statSync as statSync6 } from "fs";
4652
4750
  import { join as join10 } from "path";
4653
4751
  function localeFromLproj(dir) {
4654
4752
  const m = dir.match(/^(.+)\.lproj$/);
@@ -4715,25 +4813,34 @@ function parseStrings(text, file, warnings) {
4715
4813
  while (i < n && !/[\s=;]/.test(text[i])) raw += text[i++];
4716
4814
  return raw.length ? raw : null;
4717
4815
  };
4816
+ const recover = () => {
4817
+ while (i < n && text[i] !== ";") i++;
4818
+ if (i >= n) return false;
4819
+ i++;
4820
+ return true;
4821
+ };
4718
4822
  while (true) {
4719
4823
  skipTrivia();
4720
4824
  if (i >= n) break;
4721
4825
  const key = readToken();
4722
4826
  if (key === null) {
4723
4827
  warnings.push(`apple-strings: malformed entry in ${file} near offset ${i}`);
4724
- break;
4828
+ if (!recover()) break;
4829
+ continue;
4725
4830
  }
4726
4831
  skipTrivia();
4727
4832
  if (text[i] !== "=") {
4728
4833
  warnings.push(`apple-strings: expected '=' after key "${key}" in ${file}`);
4729
- break;
4834
+ if (!recover()) break;
4835
+ continue;
4730
4836
  }
4731
4837
  i++;
4732
4838
  skipTrivia();
4733
4839
  const value = readToken();
4734
4840
  if (value === null) {
4735
4841
  warnings.push(`apple-strings: missing value for key "${key}" in ${file}`);
4736
- break;
4842
+ if (!recover()) break;
4843
+ continue;
4737
4844
  }
4738
4845
  skipTrivia();
4739
4846
  if (text[i] === ";") i++;
@@ -4760,7 +4867,7 @@ var init_apple_strings2 = __esm({
4760
4867
  const file = join10(localeRoot, dir, TABLE);
4761
4868
  let text;
4762
4869
  try {
4763
- if (!statSync5(file).isFile()) continue;
4870
+ if (!statSync6(file).isFile()) continue;
4764
4871
  text = readFileSync15(file, "utf8");
4765
4872
  } catch {
4766
4873
  continue;
@@ -5053,11 +5160,11 @@ var init_gettext_po2 = __esm({
5053
5160
  });
5054
5161
 
5055
5162
  // src/server/import/parsers/i18next-json.ts
5056
- import { readdirSync as readdirSync11, readFileSync as readFileSync18, statSync as statSync6 } from "fs";
5163
+ import { readdirSync as readdirSync11, readFileSync as readFileSync18, statSync as statSync7 } from "fs";
5057
5164
  import { join as join13 } from "path";
5058
5165
  function safeIsDir2(p) {
5059
5166
  try {
5060
- return statSync6(p).isDirectory();
5167
+ return statSync7(p).isDirectory();
5061
5168
  } catch {
5062
5169
  return false;
5063
5170
  }
@@ -5168,6 +5275,15 @@ function decodeDouble(body) {
5168
5275
  }
5169
5276
  const n = body[++i];
5170
5277
  if (n === void 0) break;
5278
+ const hexLen = n === "x" ? 2 : n === "u" ? 4 : n === "U" ? 8 : 0;
5279
+ if (hexLen) {
5280
+ const hex = body.slice(i + 1, i + 1 + hexLen);
5281
+ if (hex.length === hexLen && /^[0-9a-fA-F]+$/.test(hex)) {
5282
+ out += String.fromCodePoint(parseInt(hex, 16));
5283
+ i += hexLen;
5284
+ continue;
5285
+ }
5286
+ }
5171
5287
  out += n === "n" ? "\n" : n === "r" ? "\r" : n === "t" ? " " : n;
5172
5288
  }
5173
5289
  return out;
@@ -5391,7 +5507,7 @@ var init_rails_yaml2 = __esm({
5391
5507
  });
5392
5508
 
5393
5509
  // src/server/import/parsers/apple-stringsdict.ts
5394
- import { readdirSync as readdirSync13, readFileSync as readFileSync20, statSync as statSync7 } from "fs";
5510
+ import { readdirSync as readdirSync13, readFileSync as readFileSync20, statSync as statSync8 } from "fs";
5395
5511
  import { join as join15 } from "path";
5396
5512
  function localeFromLproj2(dir) {
5397
5513
  const m = dir.match(/^(.+)\.lproj$/);
@@ -5537,7 +5653,7 @@ var init_apple_stringsdict2 = __esm({
5537
5653
  const file = join15(localeRoot, dir, TABLE2);
5538
5654
  let text;
5539
5655
  try {
5540
- if (!statSync7(file).isFile()) continue;
5656
+ if (!statSync8(file).isFile()) continue;
5541
5657
  text = readFileSync20(file, "utf8");
5542
5658
  } catch {
5543
5659
  continue;
@@ -6650,7 +6766,7 @@ var init_events = __esm({
6650
6766
  });
6651
6767
 
6652
6768
  // src/server/watch.ts
6653
- import { statSync as statSync8, readdirSync as readdirSync14 } from "fs";
6769
+ import { statSync as statSync9, readdirSync as readdirSync14 } from "fs";
6654
6770
  import { join as join17 } from "path";
6655
6771
  import { createHash as createHash2 } from "crypto";
6656
6772
  function hashState(state) {
@@ -6660,14 +6776,14 @@ function signature(statePath) {
6660
6776
  const fmt = detectFormat(statePath);
6661
6777
  if (fmt === "none") return "none";
6662
6778
  if (fmt === "single") {
6663
- const s = statSync8(statePath);
6779
+ const s = statSync9(statePath);
6664
6780
  return `single:${s.size}:${s.mtimeMs}`;
6665
6781
  }
6666
6782
  const dir = splitDirFor(statePath);
6667
6783
  const parts = [];
6668
6784
  for (const rel of ["config.json", "keys.json"]) {
6669
6785
  try {
6670
- const s = statSync8(join17(dir, rel));
6786
+ const s = statSync9(join17(dir, rel));
6671
6787
  parts.push(`${rel}:${s.size}:${s.mtimeMs}`);
6672
6788
  } catch {
6673
6789
  }
@@ -6675,7 +6791,7 @@ function signature(statePath) {
6675
6791
  try {
6676
6792
  for (const name of readdirSync14(join17(dir, "locales")).sort()) {
6677
6793
  if (!name.endsWith(".json")) continue;
6678
- const s = statSync8(join17(dir, "locales", name));
6794
+ const s = statSync9(join17(dir, "locales", name));
6679
6795
  parts.push(`${name}:${s.size}:${s.mtimeMs}`);
6680
6796
  }
6681
6797
  } catch {
@@ -6754,7 +6870,7 @@ var init_watch = __esm({
6754
6870
  // src/server/api.ts
6755
6871
  import { Hono } from "hono";
6756
6872
  import { streamSSE } from "hono/streaming";
6757
- import { readFileSync as readFileSync23, existsSync as existsSync13, readdirSync as readdirSync15, statSync as statSync9, rmSync as rmSync6 } from "fs";
6873
+ import { readFileSync as readFileSync23, existsSync as existsSync13, readdirSync as readdirSync15, statSync as statSync10, rmSync as rmSync6 } from "fs";
6758
6874
  import { dirname as dirname3, resolve as resolve9, basename, relative as relative4, sep as sep2 } from "path";
6759
6875
  function projectName(root) {
6760
6876
  const nameFile = resolve9(root, ".idea", ".name");
@@ -6940,7 +7056,7 @@ function createApi(deps) {
6940
7056
  filePath = abs;
6941
7057
  } else {
6942
7058
  try {
6943
- if (statSync9(abs).isDirectory()) walk(abs, depth + 1);
7059
+ if (statSync10(abs).isDirectory()) walk(abs, depth + 1);
6944
7060
  } catch {
6945
7061
  }
6946
7062
  continue;
@@ -8006,8 +8122,16 @@ async function readFileResponse(absPath) {
8006
8122
  return null;
8007
8123
  }
8008
8124
  }
8125
+ function isLocalHost(hostname) {
8126
+ const h = hostname.toLowerCase().replace(/^\[|\]$/g, "");
8127
+ return h === "localhost" || h === "127.0.0.1" || h === "::1" || h.endsWith(".localhost");
8128
+ }
8009
8129
  function buildApp(opts) {
8010
8130
  const app = new Hono2();
8131
+ app.use("*", async (c, next) => {
8132
+ if (!isLocalHost(new URL(c.req.url).hostname)) return c.text("Forbidden: non-local Host header", 403);
8133
+ return next();
8134
+ });
8011
8135
  const apiDeps = {
8012
8136
  statePath: opts.statePath,
8013
8137
  autoExport: true,
@@ -8159,7 +8283,7 @@ init_usage();
8159
8283
  init_context();
8160
8284
  init_run2();
8161
8285
  init_outputs();
8162
- import { resolve as resolve11, dirname as dirname5, join as join19 } from "path";
8286
+ import { resolve as resolve11, dirname as dirname5, join as join19, basename as basename2 } from "path";
8163
8287
  import { readFileSync as readFileSync24, existsSync as existsSync14, mkdirSync as mkdirSync6, cpSync } from "fs";
8164
8288
  import { fileURLToPath as fileURLToPath2 } from "url";
8165
8289
 
@@ -8200,7 +8324,7 @@ function formatText(report) {
8200
8324
  function formatJson(report) {
8201
8325
  return JSON.stringify(report, null, 2) + "\n";
8202
8326
  }
8203
- function formatSarif(report, rawText) {
8327
+ function formatSarif(report, ctx) {
8204
8328
  const ruleIds = [...new Set(report.findings.map((f) => f.ruleId))];
8205
8329
  const sarif = {
8206
8330
  $schema: "https://json.schemastore.org/sarif-2.1.0.json",
@@ -8208,14 +8332,24 @@ function formatSarif(report, rawText) {
8208
8332
  runs: [{
8209
8333
  tool: { driver: { name: "glotfile", rules: ruleIds.map((id) => ({ id })) } },
8210
8334
  results: report.findings.map((f) => {
8211
- const pos = locate(rawText, f.key);
8212
- return {
8335
+ const base = {
8213
8336
  ruleId: f.ruleId,
8214
- level: f.severity === "error" ? "error" : "warning",
8337
+ level: f.severity === "error" ? "error" : "warning"
8338
+ };
8339
+ if (f.ruleId === "output-stale") {
8340
+ return {
8341
+ ...base,
8342
+ message: { text: `${f.key}: ${f.message}` },
8343
+ locations: [{ physicalLocation: { artifactLocation: { uri: f.key } } }]
8344
+ };
8345
+ }
8346
+ const pos = locate(ctx.keysRawText, f.key);
8347
+ return {
8348
+ ...base,
8215
8349
  message: { text: `${f.key}${f.locale ? ` [${f.locale}]` : ""}: ${f.message}` },
8216
8350
  locations: [{
8217
8351
  physicalLocation: {
8218
- artifactLocation: { uri: "glotfile.json" },
8352
+ artifactLocation: { uri: ctx.keysUri },
8219
8353
  region: { startLine: pos.line, startColumn: pos.column }
8220
8354
  }
8221
8355
  }]
@@ -8630,9 +8764,23 @@ async function runContextBatchAction(args, pending, action, projectRoot) {
8630
8764
  if (outcome.retried) console.log(`${outcome.retried} job(s) re-run synchronously (batch entries failed or were malformed).`);
8631
8765
  for (const e of outcome.errors) console.warn(`skip ${e.key}: ${e.error}`);
8632
8766
  }
8633
- function printReport(report, format, rawText) {
8767
+ function sarifContextFor(statePath) {
8768
+ if (detectFormat(statePath) === "split") {
8769
+ const dir = splitDirFor(statePath);
8770
+ const keysPath = join19(dir, "keys.json");
8771
+ return {
8772
+ keysUri: `${basename2(dir)}/keys.json`,
8773
+ keysRawText: existsSync14(keysPath) ? readFileSync24(keysPath, "utf8") : ""
8774
+ };
8775
+ }
8776
+ return {
8777
+ keysUri: basename2(statePath),
8778
+ keysRawText: existsSync14(statePath) ? readFileSync24(statePath, "utf8") : ""
8779
+ };
8780
+ }
8781
+ function printReport(report, format, statePath) {
8634
8782
  if (format === "json") console.log(formatJson(report).trimEnd());
8635
- else if (format === "sarif") console.log(formatSarif(report, rawText).trimEnd());
8783
+ else if (format === "sarif") console.log(formatSarif(report, sarifContextFor(statePath)).trimEnd());
8636
8784
  else console.log(formatText(report).trimEnd());
8637
8785
  }
8638
8786
  async function runLintCmd(args) {
@@ -8649,13 +8797,12 @@ async function runLintCmd(args) {
8649
8797
  }
8650
8798
  return;
8651
8799
  }
8652
- const rawText = existsSync14(args.statePath) ? readFileSync24(args.statePath, "utf8") : "";
8653
8800
  const report = await runLint(state, {
8654
8801
  locales: args.locales,
8655
8802
  ruleIds: args.ruleIds,
8656
8803
  includeSuppressed: args.includeSuppressed
8657
8804
  });
8658
- printReport(report, args.format, rawText);
8805
+ printReport(report, args.format, args.statePath);
8659
8806
  const tooManyWarnings = args.maxWarnings != null && report.counts.warn > args.maxWarnings;
8660
8807
  if (!report.ok || tooManyWarnings) process.exitCode = 1;
8661
8808
  }
@@ -8669,17 +8816,16 @@ async function runCheck(args) {
8669
8816
  counts: { error: 1, warn: 0, suppressed: 0 },
8670
8817
  ok: false
8671
8818
  };
8672
- printReport(report2, args.format, "");
8819
+ printReport(report2, args.format, args.statePath);
8673
8820
  process.exitCode = 1;
8674
8821
  return;
8675
8822
  }
8676
- const rawText = existsSync14(args.statePath) ? readFileSync24(args.statePath, "utf8") : "";
8677
8823
  const root = dirname5(resolve11(args.statePath));
8678
8824
  const lint = await runLint(state, {});
8679
8825
  const findings = sortFindings([...lint.findings, ...checkOutputs(state, root)]);
8680
8826
  const counts = { ...countSeverities(findings), suppressed: lint.counts.suppressed };
8681
8827
  const report = { findings, counts, ok: counts.error === 0 };
8682
- printReport(report, args.format, rawText);
8828
+ printReport(report, args.format, args.statePath);
8683
8829
  if (!report.ok) process.exitCode = 1;
8684
8830
  }
8685
8831
  async function runImportCmd(args) {
@@ -8880,6 +9026,7 @@ async function runPrune(args) {
8880
9026
  }
8881
9027
  const state = loadState(args.statePath);
8882
9028
  const toRemove = /* @__PURE__ */ new Set();
9029
+ let heuristicUnused = false;
8883
9030
  if (args.emptySource) {
8884
9031
  for (const k of findEmptySourceKeys(state)) toRemove.add(k);
8885
9032
  }
@@ -8895,6 +9042,7 @@ async function runPrune(args) {
8895
9042
  for (const k of Object.keys(state.keys)) {
8896
9043
  if (!used.has(k)) toRemove.add(k);
8897
9044
  }
9045
+ heuristicUnused = true;
8898
9046
  }
8899
9047
  }
8900
9048
  const keys = [...toRemove].sort();
@@ -8902,6 +9050,11 @@ async function runPrune(args) {
8902
9050
  console.log("No keys to prune.");
8903
9051
  return;
8904
9052
  }
9053
+ if (heuristicUnused) {
9054
+ console.warn(
9055
+ "Note: --unused is heuristic \u2014 keys used via an unrecognised wrapper or a fully dynamic key can appear here by mistake. Review the list and add a `scan.keep` glob for any false positive."
9056
+ );
9057
+ }
8905
9058
  if (!args.write) {
8906
9059
  for (const k of keys) console.log(k);
8907
9060
  console.log(`${keys.length} key(s) to prune. Run with --write to remove them.`);