glotfile 0.8.1 → 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
  }
@@ -1579,7 +1590,8 @@ function attrEscape(s) {
1579
1590
  return xmlEscape2(s).replace(/"/g, """);
1580
1591
  }
1581
1592
  function angularXMeta(placeholders, name) {
1582
- return /^[A-Z][A-Z0-9_]*$/.test(name) ? placeholders?.[name] : void 0;
1593
+ const meta = placeholders?.[name];
1594
+ return /^[A-Z][A-Z0-9_]*$/.test(name) || meta?.origin === "x" ? meta : void 0;
1583
1595
  }
1584
1596
  function renderInterpolations(text, ids, placeholders) {
1585
1597
  let out = "";
@@ -2464,6 +2476,7 @@ var init_anthropic = __esm({
2464
2476
  }, { signal });
2465
2477
  this.recordUsage(res.usage);
2466
2478
  const text = res.content.find((b) => b.type === "text")?.text ?? "";
2479
+ if (res.stop_reason === "max_tokens") throw new MalformedReplyError(text);
2467
2480
  return parseReplyItems(text);
2468
2481
  }
2469
2482
  };
@@ -2557,6 +2570,7 @@ var init_openai = __esm({
2557
2570
  ]
2558
2571
  }, { signal });
2559
2572
  const text = res.choices?.[0]?.message?.content ?? "";
2573
+ if (res.choices?.[0]?.finish_reason === "length") throw new MalformedReplyError(text);
2560
2574
  return parseReplyItems(text);
2561
2575
  }
2562
2576
  };
@@ -2674,8 +2688,11 @@ var init_bedrock = __esm({
2674
2688
  const res = await this.client.send(this.makeCommand(this.buildInput(batch)), { abortSignal: signal });
2675
2689
  const blocks = res.output?.message?.content ?? [];
2676
2690
  const tool = blocks.find((b) => b.toolUse)?.toolUse;
2677
- if (tool?.input?.items) return tool.input.items;
2678
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;
2679
2696
  return parseReplyItems(text);
2680
2697
  }
2681
2698
  };
@@ -3131,8 +3148,30 @@ function attachScreenshotsForProvider(reqs, state, projectRoot, supportsVision)
3131
3148
  const keys = new Set(reqs.filter((r) => state.keys[r.key]?.screenshot).map((r) => r.key));
3132
3149
  return { skipped: keys.size };
3133
3150
  }
3134
- 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 = {}) {
3135
3172
  if (!reqs.length) return [];
3173
+ const maxRetries = retry.retries ?? 3;
3174
+ const delayMs = retry.delayMs ?? ((attempt) => 250 * 2 ** attempt);
3136
3175
  const byLocale = /* @__PURE__ */ new Map();
3137
3176
  for (const req of reqs) {
3138
3177
  let group = byLocale.get(req.targetLocale);
@@ -3167,10 +3206,20 @@ async function runLocaleParallel(reqs, provider, hooks = {}, concurrency = DEFAU
3167
3206
  started.add(locale);
3168
3207
  hooks.onLocaleStart?.(locale);
3169
3208
  }
3170
- const batchResults = await provider.translate(batch, (_localeDone, _localeTotal, results) => {
3171
- done += results.length;
3172
- hooks.onBatchComplete?.(done, total, results, locale);
3173
- }, 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
+ }
3174
3223
  allResults.push(...batchResults);
3175
3224
  const left = remaining.get(locale) - 1;
3176
3225
  remaining.set(locale, left);
@@ -3261,26 +3310,63 @@ var init_pending_batch = __esm({
3261
3310
  });
3262
3311
 
3263
3312
  // src/server/log.ts
3264
- 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";
3265
3314
  import { resolve as resolve5 } from "path";
3266
3315
  function logPath(projectRoot) {
3267
3316
  return resolve5(projectRoot, ".glotfile", "log.jsonl");
3268
3317
  }
3269
3318
  function appendLog(projectRoot, entry) {
3270
3319
  ensureGlotfileDir(projectRoot);
3271
- appendFileSync(logPath(projectRoot), JSON.stringify(entry) + "\n", "utf8");
3272
- }
3273
- function readLog(projectRoot, limit = 100) {
3274
3320
  const path = logPath(projectRoot);
3275
- 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;
3276
3326
  const lines = readFileSync7(path, "utf8").split("\n").filter((l) => l.trim() !== "");
3277
- const entries = lines.map((l) => JSON.parse(l));
3278
- 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
+ }
3358
+ }
3359
+ function readLog(projectRoot, limit = 100) {
3360
+ return readLastLines(logPath(projectRoot), limit).map((l) => JSON.parse(l)).reverse();
3279
3361
  }
3362
+ var MAX_LOG_BYTES, TRIM_LOG_TO_BYTES;
3280
3363
  var init_log = __esm({
3281
3364
  "src/server/log.ts"() {
3282
3365
  "use strict";
3283
3366
  init_glotfile_dir();
3367
+ init_atomic_write();
3368
+ MAX_LOG_BYTES = 5 * 1024 * 1024;
3369
+ TRIM_LOG_TO_BYTES = 4 * 1024 * 1024;
3284
3370
  }
3285
3371
  });
3286
3372
 
@@ -3872,7 +3958,7 @@ var init_scan = __esm({
3872
3958
  });
3873
3959
 
3874
3960
  // src/server/scanner.ts
3875
- 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";
3876
3962
  import { join as join5, extname as extname2, relative } from "path";
3877
3963
  function scannerForExt(ext) {
3878
3964
  return EXT_SCANNER[ext] ?? null;
@@ -4029,7 +4115,7 @@ function* walkFiles(dir, root, exclude) {
4029
4115
  const rel = relative(root, abs);
4030
4116
  let st;
4031
4117
  try {
4032
- st = statSync2(abs);
4118
+ st = statSync3(abs);
4033
4119
  } catch {
4034
4120
  continue;
4035
4121
  }
@@ -4058,7 +4144,7 @@ function runScan(projectRoot, opts, existing) {
4058
4144
  const abs = join5(projectRoot, relPath);
4059
4145
  let st;
4060
4146
  try {
4061
- st = statSync2(abs);
4147
+ st = statSync3(abs);
4062
4148
  } catch {
4063
4149
  continue;
4064
4150
  }
@@ -4109,7 +4195,21 @@ var init_scanner = __esm({
4109
4195
  // t('key') — word boundary before t, not preceded by dot (excludes i18n.t which is above)
4110
4196
  /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*'([^']+)'/g,
4111
4197
  /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*"([^"]+)"/g,
4112
- /(?<!\.)(?<![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
4113
4213
  ],
4114
4214
  gettext: [
4115
4215
  /\b(?:gettext|ngettext)\s*\(\s*'([^']+)'/g,
@@ -4143,7 +4243,7 @@ var init_scanner = __esm({
4143
4243
  /(?<!\.)(?<![a-zA-Z0-9_$])\bt\s*\(\s*`([^`$]*)\$\{/g
4144
4244
  ]
4145
4245
  };
4146
- CACHE_VERSION = 6;
4246
+ CACHE_VERSION = 7;
4147
4247
  EXT_SCANNER = {
4148
4248
  ".php": "laravel",
4149
4249
  ".vue": "js-i18n",
@@ -4187,11 +4287,11 @@ var init_scanner = __esm({
4187
4287
  });
4188
4288
 
4189
4289
  // src/server/import/detect.ts
4190
- 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";
4191
4291
  import { join as join6 } from "path";
4192
4292
  function safeIsDir(p) {
4193
4293
  try {
4194
- return statSync3(p).isDirectory();
4294
+ return statSync4(p).isDirectory();
4195
4295
  } catch {
4196
4296
  return false;
4197
4297
  }
@@ -4227,7 +4327,7 @@ function detectVue(root, forced = false) {
4227
4327
  if (enough) {
4228
4328
  const sourceLocale = pickSource(locales, (loc) => {
4229
4329
  try {
4230
- return statSync3(join6(localeRoot, `${loc}.json`)).size;
4330
+ return statSync4(join6(localeRoot, `${loc}.json`)).size;
4231
4331
  } catch {
4232
4332
  return 0;
4233
4333
  }
@@ -4264,7 +4364,7 @@ function detectApple(root) {
4264
4364
  locales,
4265
4365
  sourceLocale: pickSource(locales, (loc) => {
4266
4366
  try {
4267
- return statSync3(join6(dir, `${loc}.lproj`, "Localizable.strings")).size;
4367
+ return statSync4(join6(dir, `${loc}.lproj`, "Localizable.strings")).size;
4268
4368
  } catch {
4269
4369
  return 0;
4270
4370
  }
@@ -4324,7 +4424,7 @@ function detectI18next(root) {
4324
4424
  if (locales.length === 0) continue;
4325
4425
  const sourceLocale = pickSource(locales, (loc) => {
4326
4426
  try {
4327
- 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);
4328
4428
  } catch {
4329
4429
  return 0;
4330
4430
  }
@@ -4429,10 +4529,9 @@ var init_detect = __esm({
4429
4529
  function flattenObject(value, prefix, warnings) {
4430
4530
  const out = {};
4431
4531
  const walk = (node, path) => {
4432
- if (typeof node === "string") {
4433
- out[path] = node;
4434
- } else if (typeof node === "number" || typeof node === "boolean") {
4435
- 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);
4436
4535
  } else if (Array.isArray(node)) {
4437
4536
  node.forEach((el, i) => walk(el, path ? `${path}.${i}` : String(i)));
4438
4537
  } else if (node && typeof node === "object") {
@@ -4501,18 +4600,18 @@ var init_placeholders2 = __esm({
4501
4600
  });
4502
4601
 
4503
4602
  // src/server/import/parsers/laravel-php.ts
4504
- import { readdirSync as readdirSync6, statSync as statSync4 } from "fs";
4603
+ import { readdirSync as readdirSync6, statSync as statSync5 } from "fs";
4505
4604
  import { join as join8, relative as relative2 } from "path";
4506
4605
  import { execFileSync } from "child_process";
4507
4606
  function listDirs2(dir) {
4508
- return readdirSync6(dir).filter((e) => statSync4(join8(dir, e)).isDirectory());
4607
+ return readdirSync6(dir).filter((e) => statSync5(join8(dir, e)).isDirectory());
4509
4608
  }
4510
4609
  function listPhpFiles(dir) {
4511
4610
  const out = [];
4512
4611
  const walk = (d) => {
4513
4612
  for (const e of readdirSync6(d)) {
4514
4613
  const full = join8(d, e);
4515
- if (statSync4(full).isDirectory()) walk(full);
4614
+ if (statSync5(full).isDirectory()) walk(full);
4516
4615
  else if (e.endsWith(".php")) out.push(full);
4517
4616
  }
4518
4617
  };
@@ -4647,7 +4746,7 @@ var init_flutter_arb2 = __esm({
4647
4746
  });
4648
4747
 
4649
4748
  // src/server/import/parsers/apple-strings.ts
4650
- 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";
4651
4750
  import { join as join10 } from "path";
4652
4751
  function localeFromLproj(dir) {
4653
4752
  const m = dir.match(/^(.+)\.lproj$/);
@@ -4714,25 +4813,34 @@ function parseStrings(text, file, warnings) {
4714
4813
  while (i < n && !/[\s=;]/.test(text[i])) raw += text[i++];
4715
4814
  return raw.length ? raw : null;
4716
4815
  };
4816
+ const recover = () => {
4817
+ while (i < n && text[i] !== ";") i++;
4818
+ if (i >= n) return false;
4819
+ i++;
4820
+ return true;
4821
+ };
4717
4822
  while (true) {
4718
4823
  skipTrivia();
4719
4824
  if (i >= n) break;
4720
4825
  const key = readToken();
4721
4826
  if (key === null) {
4722
4827
  warnings.push(`apple-strings: malformed entry in ${file} near offset ${i}`);
4723
- break;
4828
+ if (!recover()) break;
4829
+ continue;
4724
4830
  }
4725
4831
  skipTrivia();
4726
4832
  if (text[i] !== "=") {
4727
4833
  warnings.push(`apple-strings: expected '=' after key "${key}" in ${file}`);
4728
- break;
4834
+ if (!recover()) break;
4835
+ continue;
4729
4836
  }
4730
4837
  i++;
4731
4838
  skipTrivia();
4732
4839
  const value = readToken();
4733
4840
  if (value === null) {
4734
4841
  warnings.push(`apple-strings: missing value for key "${key}" in ${file}`);
4735
- break;
4842
+ if (!recover()) break;
4843
+ continue;
4736
4844
  }
4737
4845
  skipTrivia();
4738
4846
  if (text[i] === ";") i++;
@@ -4759,7 +4867,7 @@ var init_apple_strings2 = __esm({
4759
4867
  const file = join10(localeRoot, dir, TABLE);
4760
4868
  let text;
4761
4869
  try {
4762
- if (!statSync5(file).isFile()) continue;
4870
+ if (!statSync6(file).isFile()) continue;
4763
4871
  text = readFileSync15(file, "utf8");
4764
4872
  } catch {
4765
4873
  continue;
@@ -4823,18 +4931,20 @@ function decodeInline(raw, addMeta) {
4823
4931
  const meta = {};
4824
4932
  if (attrs["ctype"]) meta.type = attrs["ctype"];
4825
4933
  if (equiv !== void 0) meta.example = equiv;
4934
+ if (!ANGULAR_CONVENTION_ID.test(id)) meta.origin = "x";
4826
4935
  addMeta(id, meta);
4827
4936
  }
4828
4937
  last = m.index + m[0].length;
4829
4938
  }
4830
4939
  return out + decodeEntities(raw.slice(last));
4831
4940
  }
4832
- var LOCALE_RE5, FILE_RE, angularXliff2;
4941
+ var LOCALE_RE5, FILE_RE, ANGULAR_CONVENTION_ID, angularXliff2;
4833
4942
  var init_angular_xliff2 = __esm({
4834
4943
  "src/server/import/parsers/angular-xliff.ts"() {
4835
4944
  "use strict";
4836
4945
  LOCALE_RE5 = /^[a-z]{2,3}([_-][A-Za-z]{2,4}){0,2}$/;
4837
4946
  FILE_RE = /^messages(?:\.(.+))?\.xlf$/;
4947
+ ANGULAR_CONVENTION_ID = /^[A-Z][A-Z0-9_]*$/;
4838
4948
  angularXliff2 = {
4839
4949
  name: "angular-xliff",
4840
4950
  parse(localeRoot, opts) {
@@ -5050,11 +5160,11 @@ var init_gettext_po2 = __esm({
5050
5160
  });
5051
5161
 
5052
5162
  // src/server/import/parsers/i18next-json.ts
5053
- 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";
5054
5164
  import { join as join13 } from "path";
5055
5165
  function safeIsDir2(p) {
5056
5166
  try {
5057
- return statSync6(p).isDirectory();
5167
+ return statSync7(p).isDirectory();
5058
5168
  } catch {
5059
5169
  return false;
5060
5170
  }
@@ -5165,6 +5275,15 @@ function decodeDouble(body) {
5165
5275
  }
5166
5276
  const n = body[++i];
5167
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
+ }
5168
5287
  out += n === "n" ? "\n" : n === "r" ? "\r" : n === "t" ? " " : n;
5169
5288
  }
5170
5289
  return out;
@@ -5388,7 +5507,7 @@ var init_rails_yaml2 = __esm({
5388
5507
  });
5389
5508
 
5390
5509
  // src/server/import/parsers/apple-stringsdict.ts
5391
- 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";
5392
5511
  import { join as join15 } from "path";
5393
5512
  function localeFromLproj2(dir) {
5394
5513
  const m = dir.match(/^(.+)\.lproj$/);
@@ -5534,7 +5653,7 @@ var init_apple_stringsdict2 = __esm({
5534
5653
  const file = join15(localeRoot, dir, TABLE2);
5535
5654
  let text;
5536
5655
  try {
5537
- if (!statSync7(file).isFile()) continue;
5656
+ if (!statSync8(file).isFile()) continue;
5538
5657
  text = readFileSync20(file, "utf8");
5539
5658
  } catch {
5540
5659
  continue;
@@ -6619,10 +6738,139 @@ var init_ui_prefs = __esm({
6619
6738
  }
6620
6739
  });
6621
6740
 
6741
+ // src/server/events.ts
6742
+ function createEventHub() {
6743
+ const senders = /* @__PURE__ */ new Set();
6744
+ return {
6745
+ subscribe(send) {
6746
+ senders.add(send);
6747
+ return () => senders.delete(send);
6748
+ },
6749
+ broadcast(event, data) {
6750
+ for (const send of [...senders]) {
6751
+ try {
6752
+ send(event, data);
6753
+ } catch {
6754
+ }
6755
+ }
6756
+ },
6757
+ size() {
6758
+ return senders.size;
6759
+ }
6760
+ };
6761
+ }
6762
+ var init_events = __esm({
6763
+ "src/server/events.ts"() {
6764
+ "use strict";
6765
+ }
6766
+ });
6767
+
6768
+ // src/server/watch.ts
6769
+ import { statSync as statSync9, readdirSync as readdirSync14 } from "fs";
6770
+ import { join as join17 } from "path";
6771
+ import { createHash as createHash2 } from "crypto";
6772
+ function hashState(state) {
6773
+ return createHash2("sha1").update(serializeJson(state, state.config.format)).digest("hex");
6774
+ }
6775
+ function signature(statePath) {
6776
+ const fmt = detectFormat(statePath);
6777
+ if (fmt === "none") return "none";
6778
+ if (fmt === "single") {
6779
+ const s = statSync9(statePath);
6780
+ return `single:${s.size}:${s.mtimeMs}`;
6781
+ }
6782
+ const dir = splitDirFor(statePath);
6783
+ const parts = [];
6784
+ for (const rel of ["config.json", "keys.json"]) {
6785
+ try {
6786
+ const s = statSync9(join17(dir, rel));
6787
+ parts.push(`${rel}:${s.size}:${s.mtimeMs}`);
6788
+ } catch {
6789
+ }
6790
+ }
6791
+ try {
6792
+ for (const name of readdirSync14(join17(dir, "locales")).sort()) {
6793
+ if (!name.endsWith(".json")) continue;
6794
+ const s = statSync9(join17(dir, "locales", name));
6795
+ parts.push(`${name}:${s.size}:${s.mtimeMs}`);
6796
+ }
6797
+ } catch {
6798
+ }
6799
+ return `split:${parts.join("|")}`;
6800
+ }
6801
+ function createStateWatcher(opts) {
6802
+ const intervalMs = opts.intervalMs ?? 750;
6803
+ let statePath = opts.statePath;
6804
+ let lastSig = "";
6805
+ let lastHash = "";
6806
+ let timer;
6807
+ function baseline() {
6808
+ try {
6809
+ lastSig = signature(statePath);
6810
+ lastHash = hashState(loadState(statePath));
6811
+ } catch {
6812
+ lastSig = "";
6813
+ lastHash = "";
6814
+ }
6815
+ }
6816
+ function check() {
6817
+ let sig;
6818
+ try {
6819
+ sig = signature(statePath);
6820
+ } catch {
6821
+ return;
6822
+ }
6823
+ if (sig === lastSig) return;
6824
+ let hash;
6825
+ try {
6826
+ hash = hashState(loadState(statePath));
6827
+ } catch {
6828
+ lastSig = sig;
6829
+ return;
6830
+ }
6831
+ lastSig = sig;
6832
+ if (hash !== lastHash) {
6833
+ lastHash = hash;
6834
+ opts.onChange();
6835
+ }
6836
+ }
6837
+ function noteWrite(state) {
6838
+ try {
6839
+ lastSig = signature(statePath);
6840
+ } catch {
6841
+ lastSig = "";
6842
+ }
6843
+ lastHash = hashState(state);
6844
+ }
6845
+ function retarget(next) {
6846
+ statePath = next;
6847
+ baseline();
6848
+ }
6849
+ function start() {
6850
+ if (timer) return;
6851
+ timer = setInterval(check, intervalMs);
6852
+ timer.unref?.();
6853
+ }
6854
+ function stop() {
6855
+ if (timer) clearInterval(timer);
6856
+ timer = void 0;
6857
+ }
6858
+ baseline();
6859
+ return { check, noteWrite, retarget, start, stop };
6860
+ }
6861
+ var init_watch = __esm({
6862
+ "src/server/watch.ts"() {
6863
+ "use strict";
6864
+ init_state();
6865
+ init_format();
6866
+ init_storage();
6867
+ }
6868
+ });
6869
+
6622
6870
  // src/server/api.ts
6623
6871
  import { Hono } from "hono";
6624
6872
  import { streamSSE } from "hono/streaming";
6625
- import { readFileSync as readFileSync23, existsSync as existsSync13, readdirSync as readdirSync14, statSync as statSync8, rmSync as rmSync6 } from "fs";
6873
+ import { readFileSync as readFileSync23, existsSync as existsSync13, readdirSync as readdirSync15, statSync as statSync10, rmSync as rmSync6 } from "fs";
6626
6874
  import { dirname as dirname3, resolve as resolve9, basename, relative as relative4, sep as sep2 } from "path";
6627
6875
  function projectName(root) {
6628
6876
  const nameFile = resolve9(root, ".idea", ".name");
@@ -6654,6 +6902,14 @@ function createApi(deps) {
6654
6902
  const app = new Hono();
6655
6903
  const load = () => loadState(deps.statePath);
6656
6904
  const projectRoot = dirname3(resolve9(deps.statePath));
6905
+ const hub = deps.eventHub ?? createEventHub();
6906
+ const watcher = createStateWatcher({
6907
+ statePath: deps.statePath,
6908
+ intervalMs: deps.watchIntervalMs,
6909
+ onChange: () => hub.broadcast("state-changed", JSON.stringify({ at: (/* @__PURE__ */ new Date()).toISOString() }))
6910
+ });
6911
+ deps.onWatcher?.(watcher);
6912
+ if (deps.watch) watcher.start();
6657
6913
  let translateQueue = Promise.resolve();
6658
6914
  const withTranslateLock = (fn) => {
6659
6915
  const next = translateQueue.then(fn, fn);
@@ -6675,12 +6931,30 @@ function createApi(deps) {
6675
6931
  };
6676
6932
  const persist = (s) => {
6677
6933
  saveState(deps.statePath, s);
6934
+ watcher.noteWrite(s);
6678
6935
  scheduleAutoExport(s);
6679
6936
  };
6680
6937
  const logChange = (entry) => appendLog(projectRoot, { ...entry, at: (/* @__PURE__ */ new Date()).toISOString() });
6681
6938
  const valueText = (s, key, locale) => s.keys[key]?.values[locale]?.value;
6682
6939
  const uiPrefsPath = deps.uiPrefsPath ?? defaultUiPrefsPath();
6683
6940
  app.get("/state", (c) => c.json(load()));
6941
+ app.get("/events", (c) => streamSSE(c, async (stream) => {
6942
+ const send = (event, data) => {
6943
+ void stream.writeSSE({ event, data });
6944
+ };
6945
+ const unsubscribe = hub.subscribe(send);
6946
+ stream.onAbort(unsubscribe);
6947
+ await stream.writeSSE({ event: "ready", data: "" });
6948
+ try {
6949
+ while (!stream.aborted) {
6950
+ await stream.sleep(3e4);
6951
+ if (stream.aborted) break;
6952
+ await stream.writeSSE({ event: "ping", data: "" });
6953
+ }
6954
+ } finally {
6955
+ unsubscribe();
6956
+ }
6957
+ }));
6684
6958
  app.get("/ui-prefs", (c) => c.json(loadUiPrefs(uiPrefsPath)));
6685
6959
  app.put("/ui-prefs", async (c) => {
6686
6960
  const body = await c.req.json();
@@ -6768,7 +7042,7 @@ function createApi(deps) {
6768
7042
  if (depth > 4) return;
6769
7043
  let entries = [];
6770
7044
  try {
6771
- entries = readdirSync14(dir);
7045
+ entries = readdirSync15(dir);
6772
7046
  } catch {
6773
7047
  return;
6774
7048
  }
@@ -6782,7 +7056,7 @@ function createApi(deps) {
6782
7056
  filePath = abs;
6783
7057
  } else {
6784
7058
  try {
6785
- if (statSync8(abs).isDirectory()) walk(abs, depth + 1);
7059
+ if (statSync10(abs).isDirectory()) walk(abs, depth + 1);
6786
7060
  } catch {
6787
7061
  }
6788
7062
  continue;
@@ -6813,6 +7087,7 @@ function createApi(deps) {
6813
7087
  if (!existsSync13(resolved)) return c.json({ error: "file not found" }, 400);
6814
7088
  loadState(resolved);
6815
7089
  deps.statePath = resolved;
7090
+ watcher.retarget(resolved);
6816
7091
  return c.json({ ok: true, path: resolved, name: basename(resolved), dir: projectRoot, project: basename(projectRoot) });
6817
7092
  });
6818
7093
  app.post("/keys", async (c) => {
@@ -7816,6 +8091,8 @@ var init_api = __esm({
7816
8091
  init_ui_prefs();
7817
8092
  init_local_settings();
7818
8093
  init_atomic_write();
8094
+ init_events();
8095
+ init_watch();
7819
8096
  sanitize = (s) => s.replace(/[^\w.\-]+/g, "_");
7820
8097
  screenshotDirName = (statePath) => basename(statePath).replace(/\.[^.]+$/, "") + "-screenshots";
7821
8098
  }
@@ -7830,7 +8107,7 @@ __export(server_exports, {
7830
8107
  import { Hono as Hono2 } from "hono";
7831
8108
  import { serve } from "@hono/node-server";
7832
8109
  import { fileURLToPath } from "url";
7833
- import { dirname as dirname4, join as join17, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
8110
+ import { dirname as dirname4, join as join18, resolve as resolve10, extname as extname3, sep as sep3 } from "path";
7834
8111
  import { readFile, stat } from "fs/promises";
7835
8112
  import { createServer } from "net";
7836
8113
  import open from "open";
@@ -7845,9 +8122,22 @@ async function readFileResponse(absPath) {
7845
8122
  return null;
7846
8123
  }
7847
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
+ }
7848
8129
  function buildApp(opts) {
7849
8130
  const app = new Hono2();
7850
- const apiDeps = { statePath: opts.statePath, autoExport: true };
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
+ });
8135
+ const apiDeps = {
8136
+ statePath: opts.statePath,
8137
+ autoExport: true,
8138
+ watch: opts.watch,
8139
+ onWatcher: opts.onWatcher
8140
+ };
7851
8141
  app.route("/api", createApi(apiDeps));
7852
8142
  app.get("/:dir/*", async (c, next) => {
7853
8143
  const dirSeg = c.req.param("dir");
@@ -7873,7 +8163,7 @@ function buildApp(opts) {
7873
8163
  const file = await readFileResponse(target);
7874
8164
  if (file) return file;
7875
8165
  }
7876
- const index = await readFileResponse(join17(root, "index.html"));
8166
+ const index = await readFileResponse(join18(root, "index.html"));
7877
8167
  if (index) return index;
7878
8168
  return c.notFound();
7879
8169
  });
@@ -7898,13 +8188,19 @@ function findAvailablePort(start) {
7898
8188
  });
7899
8189
  }
7900
8190
  async function startServer(opts) {
7901
- const app = buildApp(opts);
8191
+ let watcher;
8192
+ const app = buildApp({ ...opts, watch: opts.watch ?? true, onWatcher: (w) => {
8193
+ watcher = w;
8194
+ } });
7902
8195
  const port = await findAvailablePort(opts.dev ? DEV_PORT : DEFAULT_PORT);
7903
8196
  return new Promise((resolveP) => {
7904
8197
  const server = serve({ fetch: app.fetch, hostname: "127.0.0.1", port }, (info) => {
7905
8198
  const url = `http://127.0.0.1:${info.port}`;
7906
8199
  if (opts.open !== false && !opts.dev) void open(url);
7907
- resolveP({ url, close: () => server.close() });
8200
+ resolveP({ url, close: () => {
8201
+ watcher?.stop();
8202
+ server.close();
8203
+ } });
7908
8204
  backgroundScan(opts.statePath);
7909
8205
  });
7910
8206
  });
@@ -7937,7 +8233,7 @@ var init_server = __esm({
7937
8233
  init_scanner();
7938
8234
  init_usage();
7939
8235
  here = dirname4(fileURLToPath(import.meta.url));
7940
- DEFAULT_UI_DIR = join17(here, "..", "ui");
8236
+ DEFAULT_UI_DIR = join18(here, "..", "ui");
7941
8237
  MIME = {
7942
8238
  ".html": "text/html; charset=utf-8",
7943
8239
  ".js": "text/javascript; charset=utf-8",
@@ -7987,7 +8283,7 @@ init_usage();
7987
8283
  init_context();
7988
8284
  init_run2();
7989
8285
  init_outputs();
7990
- import { resolve as resolve11, dirname as dirname5, join as join18 } from "path";
8286
+ import { resolve as resolve11, dirname as dirname5, join as join19, basename as basename2 } from "path";
7991
8287
  import { readFileSync as readFileSync24, existsSync as existsSync14, mkdirSync as mkdirSync6, cpSync } from "fs";
7992
8288
  import { fileURLToPath as fileURLToPath2 } from "url";
7993
8289
 
@@ -8028,7 +8324,7 @@ function formatText(report) {
8028
8324
  function formatJson(report) {
8029
8325
  return JSON.stringify(report, null, 2) + "\n";
8030
8326
  }
8031
- function formatSarif(report, rawText) {
8327
+ function formatSarif(report, ctx) {
8032
8328
  const ruleIds = [...new Set(report.findings.map((f) => f.ruleId))];
8033
8329
  const sarif = {
8034
8330
  $schema: "https://json.schemastore.org/sarif-2.1.0.json",
@@ -8036,14 +8332,24 @@ function formatSarif(report, rawText) {
8036
8332
  runs: [{
8037
8333
  tool: { driver: { name: "glotfile", rules: ruleIds.map((id) => ({ id })) } },
8038
8334
  results: report.findings.map((f) => {
8039
- const pos = locate(rawText, f.key);
8040
- return {
8335
+ const base = {
8041
8336
  ruleId: f.ruleId,
8042
- 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,
8043
8349
  message: { text: `${f.key}${f.locale ? ` [${f.locale}]` : ""}: ${f.message}` },
8044
8350
  locations: [{
8045
8351
  physicalLocation: {
8046
- artifactLocation: { uri: "glotfile.json" },
8352
+ artifactLocation: { uri: ctx.keysUri },
8047
8353
  region: { startLine: pos.line, startColumn: pos.column }
8048
8354
  }
8049
8355
  }]
@@ -8458,9 +8764,23 @@ async function runContextBatchAction(args, pending, action, projectRoot) {
8458
8764
  if (outcome.retried) console.log(`${outcome.retried} job(s) re-run synchronously (batch entries failed or were malformed).`);
8459
8765
  for (const e of outcome.errors) console.warn(`skip ${e.key}: ${e.error}`);
8460
8766
  }
8461
- 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) {
8462
8782
  if (format === "json") console.log(formatJson(report).trimEnd());
8463
- else if (format === "sarif") console.log(formatSarif(report, rawText).trimEnd());
8783
+ else if (format === "sarif") console.log(formatSarif(report, sarifContextFor(statePath)).trimEnd());
8464
8784
  else console.log(formatText(report).trimEnd());
8465
8785
  }
8466
8786
  async function runLintCmd(args) {
@@ -8477,13 +8797,12 @@ async function runLintCmd(args) {
8477
8797
  }
8478
8798
  return;
8479
8799
  }
8480
- const rawText = existsSync14(args.statePath) ? readFileSync24(args.statePath, "utf8") : "";
8481
8800
  const report = await runLint(state, {
8482
8801
  locales: args.locales,
8483
8802
  ruleIds: args.ruleIds,
8484
8803
  includeSuppressed: args.includeSuppressed
8485
8804
  });
8486
- printReport(report, args.format, rawText);
8805
+ printReport(report, args.format, args.statePath);
8487
8806
  const tooManyWarnings = args.maxWarnings != null && report.counts.warn > args.maxWarnings;
8488
8807
  if (!report.ok || tooManyWarnings) process.exitCode = 1;
8489
8808
  }
@@ -8497,17 +8816,16 @@ async function runCheck(args) {
8497
8816
  counts: { error: 1, warn: 0, suppressed: 0 },
8498
8817
  ok: false
8499
8818
  };
8500
- printReport(report2, args.format, "");
8819
+ printReport(report2, args.format, args.statePath);
8501
8820
  process.exitCode = 1;
8502
8821
  return;
8503
8822
  }
8504
- const rawText = existsSync14(args.statePath) ? readFileSync24(args.statePath, "utf8") : "";
8505
8823
  const root = dirname5(resolve11(args.statePath));
8506
8824
  const lint = await runLint(state, {});
8507
8825
  const findings = sortFindings([...lint.findings, ...checkOutputs(state, root)]);
8508
8826
  const counts = { ...countSeverities(findings), suppressed: lint.counts.suppressed };
8509
8827
  const report = { findings, counts, ok: counts.error === 0 };
8510
- printReport(report, args.format, rawText);
8828
+ printReport(report, args.format, args.statePath);
8511
8829
  if (!report.ok) process.exitCode = 1;
8512
8830
  }
8513
8831
  async function runImportCmd(args) {
@@ -8708,6 +9026,7 @@ async function runPrune(args) {
8708
9026
  }
8709
9027
  const state = loadState(args.statePath);
8710
9028
  const toRemove = /* @__PURE__ */ new Set();
9029
+ let heuristicUnused = false;
8711
9030
  if (args.emptySource) {
8712
9031
  for (const k of findEmptySourceKeys(state)) toRemove.add(k);
8713
9032
  }
@@ -8723,6 +9042,7 @@ async function runPrune(args) {
8723
9042
  for (const k of Object.keys(state.keys)) {
8724
9043
  if (!used.has(k)) toRemove.add(k);
8725
9044
  }
9045
+ heuristicUnused = true;
8726
9046
  }
8727
9047
  }
8728
9048
  const keys = [...toRemove].sort();
@@ -8730,6 +9050,11 @@ async function runPrune(args) {
8730
9050
  console.log("No keys to prune.");
8731
9051
  return;
8732
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
+ }
8733
9058
  if (!args.write) {
8734
9059
  for (const k of keys) console.log(k);
8735
9060
  console.log(`${keys.length} key(s) to prune. Run with --write to remove them.`);
@@ -8751,10 +9076,10 @@ function runSplit(args) {
8751
9076
  `Split catalog into ${splitDirFor(args.statePath)}/ (config.json, keys.json, locales/ \u2014 up to ${state.config.locales.length} locale files). Removed ${args.statePath}.`
8752
9077
  );
8753
9078
  }
8754
- var SKILL_SRC = join18(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "skill");
9079
+ var SKILL_SRC = join19(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "skill");
8755
9080
  function runSkill(args) {
8756
9081
  if (args.print) {
8757
- console.log(readFileSync24(join18(SKILL_SRC, "SKILL.md"), "utf8").trimEnd());
9082
+ console.log(readFileSync24(join19(SKILL_SRC, "SKILL.md"), "utf8").trimEnd());
8758
9083
  return;
8759
9084
  }
8760
9085
  const dest = resolve11(process.cwd(), ".claude", "skills", "glotfile");
@@ -8916,7 +9241,7 @@ ${formatOpts([...options, ...GLOBAL_OPTS])}`);
8916
9241
  );
8917
9242
  }
8918
9243
  function printVersion() {
8919
- const pkgPath = join18(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "package.json");
9244
+ const pkgPath = join19(dirname5(fileURLToPath2(import.meta.url)), "..", "..", "package.json");
8920
9245
  console.log(JSON.parse(readFileSync24(pkgPath, "utf8")).version);
8921
9246
  }
8922
9247
  async function main(argv) {