polycast-cli 0.2.0 → 0.3.0

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.
package/dist/api.d.ts CHANGED
@@ -41,7 +41,22 @@ export declare const admin: {
41
41
  etag: string;
42
42
  }>;
43
43
  }>;
44
+ /** Runtime miss telemetry (newer servers only — callers should treat a
45
+ * CliError with status 404 as "endpoint not there yet" and hide). */
46
+ misses: (api: string, token: string, projectId: string, limit?: number) => Promise<{
47
+ misses: MissRow[];
48
+ }>;
44
49
  };
50
+ export interface MissRow {
51
+ key: string;
52
+ locale: string;
53
+ count: number;
54
+ fallback: "bundled" | "key";
55
+ firstSeen: string;
56
+ lastSeen: string;
57
+ /** false = the scanner never captured this key — it needs adding. */
58
+ inCatalog: boolean;
59
+ }
45
60
  export interface ProjectStatus {
46
61
  project: {
47
62
  id: string;
package/dist/api.js CHANGED
@@ -23,9 +23,9 @@ async function request(url, init = {}) {
23
23
  if (!res.ok) {
24
24
  const e = body?.error;
25
25
  if (res.status === 401) {
26
- throw new CliError(e?.message ?? "unauthorized — run `polycast login` (admin) or check polycast.json (project key)");
26
+ throw new CliError(e?.message ?? "unauthorized — run `polycast login` (admin) or check polycast.json (project key)", 1, 401);
27
27
  }
28
- throw new CliError(`${init.method ?? "GET"} ${new URL(url).pathname} → ${res.status}: ${e?.message ?? text.slice(0, 200)}`);
28
+ throw new CliError(`${init.method ?? "GET"} ${new URL(url).pathname} → ${res.status}: ${e?.message ?? text.slice(0, 200)}`, 1, res.status);
29
29
  }
30
30
  return body;
31
31
  }
@@ -45,6 +45,9 @@ export const admin = {
45
45
  status: (api, token, projectId) => request(`${api}/v1/admin/projects/${projectId}/status`, { auth: token }),
46
46
  getProject: (api, token, projectId) => request(`${api}/v1/admin/projects/${projectId}`, { auth: token }),
47
47
  publish: (api, token, projectId, locales) => request(`${api}/v1/admin/projects/${projectId}/publish`, { method: "POST", auth: token, body: JSON.stringify(locales?.length ? { locales } : {}) }),
48
+ /** Runtime miss telemetry (newer servers only — callers should treat a
49
+ * CliError with status 404 as "endpoint not there yet" and hide). */
50
+ misses: (api, token, projectId, limit) => request(`${api}/v1/admin/projects/${projectId}/misses${limit !== undefined ? `?limit=${limit}` : ""}`, { auth: token }),
48
51
  };
49
52
  // ---- Project key surfaces ----
50
53
  export const project = {
package/dist/api.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"api.js","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"api.js","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"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"}
@@ -4,12 +4,15 @@ export interface Flags {
4
4
  wait: boolean;
5
5
  check: boolean;
6
6
  token: boolean;
7
+ add: boolean;
8
+ apply: boolean;
7
9
  name?: string;
8
10
  catalog?: string;
9
11
  endpoint?: string;
10
12
  output?: string;
11
13
  locales?: string;
12
14
  org?: string;
15
+ limit?: string;
13
16
  }
14
17
  export declare function login(flags: Flags): Promise<void>;
15
18
  export declare function whoami(flags: Flags): Promise<void>;
@@ -18,6 +21,13 @@ export declare function init(flags: Flags): Promise<void>;
18
21
  export declare function langs(args: string[], flags: Flags): Promise<void>;
19
22
  export declare function push(flags: Flags): Promise<void>;
20
23
  export declare function pull(flags: Flags): Promise<void>;
24
+ /** List (dry-run) or delete (--apply) catalog junk: letterless non-
25
+ * structural artifacts and dangling concatenation fragments ("Save " when
26
+ * "Save 50%" exists). */
27
+ export declare function prune(flags: Flags): Promise<void>;
28
+ /** Runtime miss telemetry: keys devices requested that the OTA bundle
29
+ * could not serve (admin endpoint; newer servers only). */
30
+ export declare function misses(flags: Flags): Promise<void>;
21
31
  export declare function publish(args: string[], flags: Flags): Promise<void>;
22
32
  export declare function status(flags: Flags): Promise<void>;
23
33
  export declare function doctor(): Promise<void>;
package/dist/commands.js CHANGED
@@ -6,8 +6,32 @@ import { S, c, g, bold, dim, green, red, yellow, accent, header, section, kv, ba
6
6
  import { DEFAULT_API, DEFAULT_EDGE, findConfig, requireConfig, writeConfig, ensureGitignored, stateDir, } from "./config.js";
7
7
  import { admin, edge, project, requireAdminToken } from "./api.js";
8
8
  import { storeToken, loadToken, clearToken } from "./keychain.js";
9
- import { parseCatalog, mergeTranslations, writeCatalog, serializeCatalog, addMissingKeys } from "./xcstrings.js";
10
- import { scanSwiftSources } from "./scan.js";
9
+ import { parseCatalog, mergeTranslations, writeCatalog, serializeCatalog, addMissingKeys, mergeScannerComments, catalogLocales, } from "./xcstrings.js";
10
+ import * as scanner from "./scan.js";
11
+ import { compileDenyKeys, isFileDenied, partitionPush, pruneCandidates, findPbxprojs, findFilesNamed, hasMemberImportVisibility, parseKnownRegions, userFacingInfoPlistKeys, } from "./hygiene.js";
12
+ /** Scan Swift sources, preferring the detailed scanner (feature-detected —
13
+ * strings + walk stats) and applying polycast.json denyFiles. denyFiles
14
+ * must apply per-occurrence BEFORE the scanner's cross-file dedupe (which
15
+ * attributes each key to the first file walked) — a post-hoc filter would
16
+ * drop keys that also legitimately occur in allowed files. */
17
+ function scanProject(dir, config) {
18
+ const denyFiles = config.denyFiles ?? [];
19
+ const fileFilter = denyFiles.length > 0
20
+ ? (rel) => !isFileDenied(rel, denyFiles)
21
+ : undefined;
22
+ if (typeof scanner.scanSwiftSourcesDetailed === "function") {
23
+ return scanner.scanSwiftSourcesDetailed(dir, {
24
+ extraCalls: config.scanCalls ?? [],
25
+ ...(fileFilter !== undefined ? { fileFilter } : {}),
26
+ });
27
+ }
28
+ // Legacy scanner has no fileFilter hook — post-filter is best-effort.
29
+ const result = { strings: scanner.scanSwiftSources(dir, config.scanCalls ?? []) };
30
+ if (fileFilter !== undefined) {
31
+ result.strings = result.strings.filter((s) => fileFilter(s.file));
32
+ }
33
+ return result;
34
+ }
11
35
  const apiOf = (config) => config?.endpoint ?? DEFAULT_API;
12
36
  const edgeOf = (config) => config?.edgeEndpoint ?? DEFAULT_EDGE;
13
37
  /** Resolve the project id for admin commands from polycast.json's key. */
@@ -302,31 +326,52 @@ export async function langs(args, flags) {
302
326
  export async function push(flags) {
303
327
  const { config, dir } = requireConfig();
304
328
  const catalogFile = path.resolve(dir, config.catalogPath);
329
+ const denyKeys = compileDenyKeys(config.denyKeys ?? []); // fail fast on bad config
330
+ const protectedTerms = new Set(config.protectedTerms ?? []);
305
331
  // Zero-config capture: PText / Polycast.string literals in Swift
306
332
  // sources are merged into the catalog automatically — write code,
307
- // run push, done.
308
- const scanned = scanSwiftSources(dir, config.scanCalls ?? []);
333
+ // run push, done. denyFiles filtering happens inside scanProject.
334
+ const scanned = scanProject(dir, config).strings;
309
335
  {
310
336
  const { raw } = parseCatalog(catalogFile);
311
337
  const added = addMissingKeys(raw, scanned);
312
- if (added.length > 0) {
338
+ const comments = mergeScannerComments(raw, scanned);
339
+ if (added.length > 0 || comments.adopted.length > 0)
313
340
  writeCatalog(catalogFile, raw);
341
+ if (added.length > 0) {
314
342
  console.log(`\n ${S.ok} captured ${bold(String(added.length))} new string(s) from source ${c.muted(`→ ${config.catalogPath}`)}`);
315
343
  for (const key of added.slice(0, 8))
316
344
  console.log(` ${c.positive("+")} ${c.text(key)}`);
317
345
  if (added.length > 8)
318
346
  console.log(` ${c.muted(`… and ${added.length - 8} more`)}`);
319
347
  }
348
+ if (comments.adopted.length > 0) {
349
+ console.log(` ${S.ok} adopted ${comments.adopted.length} scanner comment(s) into the catalog`);
350
+ }
351
+ for (const col of comments.collisions) {
352
+ console.log(` ${S.warn} ${yellow(`comment collision on "${col.key}"`)} ${c.muted(`catalog: "${col.catalog}" ${g.sep} scanned: "${col.scanned}" — catalog wins`)}`);
353
+ }
320
354
  }
321
355
  const { sourceLocale, strings } = parseCatalog(catalogFile);
356
+ const parts = partitionPush(strings, denyKeys, protectedTerms);
322
357
  console.log(`\n ${S.diamond} ${c.text(`${strings.length} strings`)} ${c.muted(`(catalog + source scan) from ${config.catalogPath}`)}`);
358
+ if (parts.noTranslate > 0) {
359
+ console.log(` ${c.muted(`${g.sep} ${parts.noTranslate} excluded (shouldTranslate = false)`)}`);
360
+ }
361
+ if (parts.denied.length > 0) {
362
+ const shown = parts.denied.slice(0, 5).map((k) => JSON.stringify(k)).join(", ");
363
+ console.log(` ${c.muted(`${g.sep} ${parts.denied.length} excluded by denyKeys: ${shown}${parts.denied.length > 5 ? ` … and ${parts.denied.length - 5} more` : ""}`)}`);
364
+ }
365
+ if (parts.protectedHits.length > 0) {
366
+ console.log(` ${S.warn} ${yellow(`protected term(s) kept verbatim, never pushed: ${parts.protectedHits.map((k) => JSON.stringify(k)).join(", ")}`)}`);
367
+ }
323
368
  const stateFile = path.join(stateDir(dir), "last-push.json");
324
369
  const previous = existsSync(stateFile)
325
370
  ? (JSON.parse(readFileSync(stateFile, "utf8")).digests ?? {})
326
371
  : {};
327
- const changedOrNew = strings.filter((s) => previous[s.key] !== s.digest);
372
+ const changedOrNew = parts.send.filter((s) => previous[s.key] !== s.digest);
328
373
  const newCount = changedOrNew.filter((s) => !(s.key in previous)).length;
329
- console.log(` ${green(`+${newCount} new`)} ${S.dot} ${yellow(`~${changedOrNew.length - newCount} changed`)} ${S.dot} ${dim(`${strings.length - changedOrNew.length} unchanged`)}\n`);
374
+ console.log(` ${green(`+${newCount} new`)} ${S.dot} ${yellow(`~${changedOrNew.length - newCount} changed`)} ${S.dot} ${dim(`${parts.send.length - changedOrNew.length} unchanged`)}\n`);
330
375
  if (changedOrNew.length === 0)
331
376
  return;
332
377
  const sp = spin("pushing source strings");
@@ -334,13 +379,19 @@ export async function push(flags) {
334
379
  schemaVersion: 1,
335
380
  projectKey: config.projectKey,
336
381
  sourceLocale,
337
- strings: changedOrNew.map(({ key, comment, pluralShape, digest }) => ({
338
- key, ...(comment !== undefined ? { comment } : {}),
339
- ...(pluralShape ? { pluralShape } : {}), digest,
382
+ strings: changedOrNew.map(({ key, value, comment, pluralShape, structural, digest }) => ({
383
+ key,
384
+ ...(value !== undefined ? { value } : {}),
385
+ ...(comment !== undefined ? { comment } : {}),
386
+ ...(pluralShape ? { pluralShape } : {}),
387
+ ...(structural ? { structural: true } : {}),
388
+ digest,
340
389
  })),
341
390
  });
342
391
  const digests = { ...previous };
343
- for (const s of strings)
392
+ // Record only keys actually eligible for push un-denying/un-protecting
393
+ // a key later must make it show up as new, not "unchanged".
394
+ for (const s of parts.send)
344
395
  digests[s.key] = s.digest;
345
396
  writeFileSync(stateFile, JSON.stringify({ digests }, null, 2));
346
397
  sp.succeed(`pushed — ${accent(String(result.draftJobsEnqueued))} draft job(s) queued across enabled languages`);
@@ -355,10 +406,36 @@ export async function pull(flags) {
355
406
  const wanted = flags.locales?.split(",").map((s) => s.trim()).filter(Boolean);
356
407
  const locales = manifest.locales.map((l) => l.id)
357
408
  .filter((id) => !wanted || wanted.includes(id));
358
- let changed = 0;
409
+ const bundles = new Map();
359
410
  for (const locale of locales) {
360
- const bundle = await edge.bundle(edgeOf(config), config.projectKey, locale);
361
- changed += mergeTranslations(raw, locale, bundle.strings);
411
+ bundles.set(locale, (await edge.bundle(edgeOf(config), config.projectKey, locale)).strings);
412
+ }
413
+ const missing = new Set();
414
+ let changed = 0;
415
+ for (const [locale, strings] of bundles) {
416
+ changed += mergeTranslations(raw, locale, strings, { missing });
417
+ }
418
+ // Server keys with no local entry are never dropped silently: report
419
+ // them, and create them with --add (en value = key; plural skeleton
420
+ // when any bundle serves plural variants).
421
+ let addedFromServer = [];
422
+ if (missing.size > 0 && flags.add && !flags.check) {
423
+ addedFromServer = [...missing].sort();
424
+ addMissingKeys(raw, addedFromServer.map((key) => ({
425
+ key,
426
+ kind: [...bundles.values()].some((b) => b[key]?.plural) ? "plural" : "simple",
427
+ })));
428
+ for (const [locale, strings] of bundles)
429
+ changed += mergeTranslations(raw, locale, strings);
430
+ }
431
+ else if (missing.size > 0) {
432
+ const first = [...missing].sort().slice(0, 5);
433
+ console.log(` ${S.warn} ${yellow(`${missing.size} server key(s) missing from the local catalog — not merged`)}`);
434
+ for (const key of first)
435
+ console.log(` ${c.muted(g.sep)} ${c.text(JSON.stringify(key))}`);
436
+ if (missing.size > 5)
437
+ console.log(` ${c.muted(`… and ${missing.size - 5} more`)}`);
438
+ note("run `polycast pull --add` to add them to the catalog (en value = key)");
362
439
  }
363
440
  const after = serializeCatalog(raw);
364
441
  if (flags.check) {
@@ -374,8 +451,97 @@ export async function pull(flags) {
374
451
  return;
375
452
  }
376
453
  writeCatalog(catalogFile, raw);
454
+ if (addedFromServer.length > 0) {
455
+ console.log(` ${S.ok} added ${bold(String(addedFromServer.length))} server key(s) to the catalog`);
456
+ for (const key of addedFromServer.slice(0, 5))
457
+ console.log(` ${c.positive("+")} ${c.text(key)}`);
458
+ if (addedFromServer.length > 5)
459
+ console.log(` ${c.muted(`… and ${addedFromServer.length - 5} more`)}`);
460
+ }
377
461
  success(`merged ${locales.map(flag).join(" ")} ${locales.join(", ")} — ${changed} value(s) updated`);
378
462
  }
463
+ // ---- prune ----
464
+ /** List (dry-run) or delete (--apply) catalog junk: letterless non-
465
+ * structural artifacts and dangling concatenation fragments ("Save " when
466
+ * "Save 50%" exists). */
467
+ export async function prune(flags) {
468
+ const { config, dir } = requireConfig();
469
+ const catalogFile = path.resolve(dir, config.catalogPath);
470
+ const { raw } = parseCatalog(catalogFile);
471
+ const { artifacts, fragments } = pruneCandidates(Object.keys(raw.strings));
472
+ const all = [...artifacts, ...fragments];
473
+ if (!flags.json) {
474
+ if (all.length === 0) {
475
+ success("catalog is clean — nothing to prune");
476
+ }
477
+ else {
478
+ header("prune", `${all.length} candidate(s) in ${config.catalogPath}`);
479
+ for (const key of artifacts) {
480
+ console.log(` ${c.negative("−")} ${c.text(JSON.stringify(key))} ${c.muted("empty/letterless artifact")}`);
481
+ }
482
+ for (const key of fragments) {
483
+ console.log(` ${c.negative("−")} ${c.text(JSON.stringify(key))} ${c.muted("dangling concatenation fragment (strict prefix of another key)")}`);
484
+ }
485
+ }
486
+ }
487
+ // Delete BEFORE reporting: `applied` in the machine output must reflect
488
+ // a write that actually happened, not one that was about to.
489
+ const applied = flags.apply && all.length > 0;
490
+ if (applied) {
491
+ for (const key of all)
492
+ delete raw.strings[key];
493
+ writeCatalog(catalogFile, raw);
494
+ }
495
+ if (flags.json) {
496
+ console.log(JSON.stringify({ artifacts, fragments, applied }, null, 2));
497
+ return;
498
+ }
499
+ if (all.length === 0)
500
+ return;
501
+ if (!flags.apply) {
502
+ console.log();
503
+ note("dry run — re-run with --apply to delete these from the catalog");
504
+ console.log();
505
+ return;
506
+ }
507
+ success(`pruned ${all.length} key(s) from ${config.catalogPath}`);
508
+ }
509
+ // ---- misses ----
510
+ /** Runtime miss telemetry: keys devices requested that the OTA bundle
511
+ * could not serve (admin endpoint; newer servers only). */
512
+ export async function misses(flags) {
513
+ const { config, token, projectId } = await resolveProject();
514
+ let limit = 100;
515
+ if (flags.limit !== undefined) {
516
+ limit = Number(flags.limit);
517
+ if (!Number.isInteger(limit) || limit < 1)
518
+ throw new CliError("--limit must be a positive integer", 2);
519
+ }
520
+ const { misses: rows } = await admin.misses(apiOf(config), token, projectId, limit);
521
+ if (flags.json) {
522
+ console.log(JSON.stringify(rows, null, 2));
523
+ return;
524
+ }
525
+ if (rows.length === 0) {
526
+ success("no runtime misses reported");
527
+ return;
528
+ }
529
+ const sorted = [...rows].sort((a, b) => b.count - a.count);
530
+ header("runtime misses", `${rows.length} key/locale pair(s) the SDK could not serve OTA`);
531
+ console.log(table(["key", "locale", "count", "fallback", "last seen"], sorted.map((r) => [
532
+ c.text(r.key) + (r.inCatalog ? "" : ` ${c.danger("(uncaptured)")}`),
533
+ c.accent2(r.locale),
534
+ String(r.count),
535
+ c.muted(r.fallback),
536
+ c.muted(relativeTime(r.lastSeen)),
537
+ ])));
538
+ const uncaptured = sorted.filter((r) => !r.inCatalog).length;
539
+ console.log();
540
+ if (uncaptured > 0) {
541
+ note(`${uncaptured} uncaptured key(s) — the scanner never saw them; check scanCalls in polycast.json or add them to the catalog`);
542
+ console.log();
543
+ }
544
+ }
379
545
  // ---- publish ----
380
546
  export async function publish(args, flags) {
381
547
  const { config, token, projectId } = await resolveProject();
@@ -447,6 +613,22 @@ export async function status(flags) {
447
613
  ? `${c.text(`bundle v${latest.bundleVersion}`)} ${c.positive("live")} ${c.muted(`${g.sep} published ${relativeTime(latest.at)}`)}`
448
614
  : c.muted("nothing published yet"));
449
615
  kv("jobs", failed > 0 ? c.negative(`${failed} failed — inspect with status --json`) : c.positive("all healthy"));
616
+ // Runtime misses (newer servers only — a 404 means the endpoint does
617
+ // not exist yet; hide the line so old servers keep working).
618
+ try {
619
+ const { misses: missRows } = await admin.misses(apiOf(config), token, projectId, 500);
620
+ if (missRows.length > 0) {
621
+ const total = missRows.reduce((a, r) => a + r.count, 0);
622
+ const uncaptured = missRows.filter((r) => !r.inCatalog).length;
623
+ kv("runtime misses", c.danger(`${total} across ${missRows.length} key/locale pair(s)`)
624
+ + (uncaptured > 0 ? c.muted(` ${g.sep} ${uncaptured} uncaptured`) : "")
625
+ + c.muted(` ${g.sep} see \`polycast misses\``));
626
+ }
627
+ }
628
+ catch {
629
+ // Telemetry is informational — a transient 5xx/timeout must never
630
+ // take down `status`. Only `polycast misses` hard-fails.
631
+ }
450
632
  section("languages");
451
633
  console.log(table(["", "locale", "coverage", "", "drafts", "review", "bundle"], enabled.map((l) => [
452
634
  flag(l.locale),
@@ -482,6 +664,7 @@ export async function doctor() {
482
664
  await check("polycast.json present and valid", () => {
483
665
  if (!found)
484
666
  throw new Error("run `polycast init`");
667
+ compileDenyKeys(found.config.denyKeys ?? []); // invalid regex = invalid config
485
668
  return found.config.projectKey.slice(0, 12) + "…";
486
669
  });
487
670
  if (!found) {
@@ -540,6 +723,102 @@ export async function doctor() {
540
723
  if (cached.status !== 304)
541
724
  throw new Error(`expected 304, got ${cached.status}`);
542
725
  });
726
+ // ---- warn-only hygiene (informational; never fails doctor) ----
727
+ const warnOnly = async (label, fn) => {
728
+ const sp = spin(label);
729
+ try {
730
+ const r = await fn();
731
+ const warnings = r.warnings ?? [];
732
+ if (warnings.length === 0) {
733
+ sp.succeed(`${label}${r.detail ? dim(` ${r.detail}`) : ""}`);
734
+ return;
735
+ }
736
+ sp.warn(`${label}${r.detail ? dim(` ${r.detail}`) : ""}`);
737
+ for (const w of warnings)
738
+ console.log(` ${S.warn} ${yellow(w)}`);
739
+ }
740
+ catch (err) {
741
+ sp.warn(`${label} ${dim(`— skipped (${err.message})`)}`);
742
+ }
743
+ };
744
+ const rel = (f) => path.relative(dir, f) || ".";
745
+ const pbxprojs = findPbxprojs(dir);
746
+ await warnOnly("source scan", () => {
747
+ const r = scanProject(dir, config);
748
+ if (!r.stats)
749
+ return { detail: `${r.strings.length} string(s)` };
750
+ const skipped = r.stats.dirsSkipped.length > 0
751
+ ? ` (${r.stats.dirsSkipped.slice(0, 4).map(rel).join(", ")}${r.stats.dirsSkipped.length > 4 ? ", …" : ""})`
752
+ : "";
753
+ return { detail: `${r.strings.length} string(s) ${g.sep} ${r.stats.filesScanned} file(s) scanned ${g.sep} ${r.stats.dirsSkipped.length} dir(s) skipped${skipped}` };
754
+ });
755
+ await warnOnly("member import visibility", () => {
756
+ if (pbxprojs.length === 0)
757
+ return { detail: "no .pbxproj — skipped" };
758
+ const bad = pbxprojs.filter((f) => hasMemberImportVisibility(readFileSync(f, "utf8")));
759
+ return {
760
+ warnings: bad.map((f) => `${rel(f)}: SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES breaks the Text shadow — set it to NO`),
761
+ };
762
+ });
763
+ await warnOnly("Text shadow coverage", () => {
764
+ const roots = detectModuleRoots(dir);
765
+ const missing = roots.filter((r) => !existsSync(path.join(r, "Polycast+Shadow.swift")));
766
+ return {
767
+ detail: `${roots.length - missing.length}/${roots.length} module(s) shadowed`,
768
+ warnings: missing.map((r) => `module ${rel(r)} has no Polycast+Shadow.swift — plain Text stays bundled-only there (run \`polycast shadow\`)`),
769
+ };
770
+ });
771
+ await warnOnly("Info.plist localization", () => {
772
+ const sources = [...findFilesNamed(dir, (n) => n === "Info.plist"), ...pbxprojs];
773
+ const keys = new Set();
774
+ for (const f of sources) {
775
+ for (const k of userFacingInfoPlistKeys(readFileSync(f, "utf8")))
776
+ keys.add(k);
777
+ }
778
+ if (keys.size === 0)
779
+ return { detail: "no user-facing Info.plist keys" };
780
+ if (findFilesNamed(dir, (n) => n === "InfoPlist.xcstrings").length > 0) {
781
+ return { detail: `${keys.size} user-facing key(s), InfoPlist.xcstrings present` };
782
+ }
783
+ return {
784
+ warnings: [`user-facing Info.plist key(s) without an InfoPlist.xcstrings — permission prompts stay unlocalized: ${[...keys].sort().join(", ")}`],
785
+ };
786
+ });
787
+ await warnOnly("Xcode knownRegions", () => {
788
+ if (pbxprojs.length === 0)
789
+ return { detail: "no .pbxproj — skipped" };
790
+ const { raw } = parseCatalog(path.resolve(dir, config.catalogPath));
791
+ const locales = catalogLocales(raw).filter((l) => l !== raw.sourceLanguage);
792
+ if (locales.length === 0)
793
+ return { detail: "no translated locales in the catalog yet" };
794
+ const warnings = [];
795
+ for (const f of pbxprojs) {
796
+ const regions = new Set(parseKnownRegions(readFileSync(f, "utf8")));
797
+ if (regions.size === 0)
798
+ continue;
799
+ const missing = locales.filter((l) => !regions.has(l));
800
+ if (missing.length > 0) {
801
+ warnings.push(`${rel(f)}: knownRegions missing ${missing.join(", ")} — add them in Xcode (Project → Info → Localizations)`);
802
+ }
803
+ }
804
+ return { detail: warnings.length === 0 ? `${locales.length} catalog locale(s) covered` : undefined, warnings };
805
+ });
806
+ await warnOnly("pull currency", async () => {
807
+ const manifest = await edge.manifest(edgeOf(config), config.projectKey);
808
+ if (manifest.locales.length === 0)
809
+ return { detail: "no published bundles yet" };
810
+ const { raw } = parseCatalog(path.resolve(dir, config.catalogPath));
811
+ const stale = [];
812
+ for (const l of manifest.locales) {
813
+ const bundle = await edge.bundle(edgeOf(config), config.projectKey, l.id);
814
+ const clone = structuredClone(raw);
815
+ if (mergeTranslations(clone, l.id, bundle.strings) > 0)
816
+ stale.push(l.id);
817
+ }
818
+ if (stale.length === 0)
819
+ return { detail: `catalog current with ${manifest.locales.length} published locale(s)` };
820
+ return { warnings: [`catalog fallbacks stale for ${stale.join(", ")} — run \`polycast pull\``] };
821
+ });
543
822
  if (failures > 0) {
544
823
  console.log(`\n ${S.fail} ${red(`${failures} check(s) failed`)}\n`);
545
824
  process.exit(1);