@leejungkiin/awkit 1.7.1 → 1.7.4

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.
Files changed (245) hide show
  1. package/bin/awk.js +576 -84
  2. package/core/CLAUDE.md +1 -1
  3. package/core/GEMINI.md +148 -167
  4. package/core/GEMINI.md.bak +149 -116
  5. package/core/skill-runtime-manifest.json +3 -0
  6. package/docs/Claude Fable 5.md +3826 -0
  7. package/docs/android_kotlin_system_instruction.md +210 -0
  8. package/docs/brainstorm_ponytail_integration.md +146 -0
  9. package/docs/brainstorm_smart_setup.md +113 -0
  10. package/docs/deep-research-report (1).md +293 -0
  11. package/docs/history/GEMINI.v1.md +135 -0
  12. package/docs/history/brainstorm_antigravity_unified_architecture.v1.md +105 -0
  13. package/docs/history/implementation_plan.v1.md +58 -0
  14. package/package.json +4 -1
  15. package/scripts/artifact-storage.js +130 -0
  16. package/scripts/automation-gate.js +35 -2
  17. package/scripts/claude-plan.js +76 -0
  18. package/scripts/dependency-manager.js +210 -0
  19. package/scripts/exec-rtk.js +11 -5
  20. package/scripts/i18n-helper.js +381 -0
  21. package/scripts/multi-model-pipeline.js +144 -0
  22. package/skill-packs/mobile-ios/pack.json +4 -2
  23. package/skill-packs/reverse-engineering/pack.json +1 -0
  24. package/skills/CATALOG.md +20 -0
  25. package/skills/GEMINI.md +9 -1
  26. package/skills/TRIGGER_INDEX.md +10 -0
  27. package/skills/ai-music/SKILL.md +275 -0
  28. package/skills/android-re-analyzer/SKILL.md +238 -0
  29. package/skills/android-re-analyzer/references/api-extraction-patterns.md +119 -0
  30. package/skills/android-re-analyzer/references/call-flow-analysis.md +176 -0
  31. package/skills/android-re-analyzer/references/fernflower-usage.md +115 -0
  32. package/skills/android-re-analyzer/references/jadx-usage.md +116 -0
  33. package/skills/android-re-analyzer/references/setup-guide.md +221 -0
  34. package/skills/android-re-analyzer/scripts/check-deps.sh +129 -0
  35. package/skills/android-re-analyzer/scripts/decompile.sh +375 -0
  36. package/skills/android-re-analyzer/scripts/find-api-calls.sh +118 -0
  37. package/skills/android-re-analyzer/scripts/install-dep.sh +448 -0
  38. package/skills/animal-island-ui-style/SKILL.md +1450 -0
  39. package/skills/app-store-review-agent/SKILL.md +164 -0
  40. package/skills/app-store-review-agent/references/guidelines/README.md +154 -0
  41. package/skills/app-store-review-agent/references/guidelines/by-app-type/ai_apps.md +37 -0
  42. package/skills/app-store-review-agent/references/guidelines/by-app-type/all_apps.md +50 -0
  43. package/skills/app-store-review-agent/references/guidelines/by-app-type/crypto_finance.md +31 -0
  44. package/skills/app-store-review-agent/references/guidelines/by-app-type/games.md +31 -0
  45. package/skills/app-store-review-agent/references/guidelines/by-app-type/health_fitness.md +31 -0
  46. package/skills/app-store-review-agent/references/guidelines/by-app-type/kids.md +27 -0
  47. package/skills/app-store-review-agent/references/guidelines/by-app-type/macos.md +38 -0
  48. package/skills/app-store-review-agent/references/guidelines/by-app-type/social_ugc.md +32 -0
  49. package/skills/app-store-review-agent/references/guidelines/by-app-type/subscription_iap.md +34 -0
  50. package/skills/app-store-review-agent/references/guidelines/by-app-type/vpn.md +18 -0
  51. package/skills/app-store-review-agent/references/rules/design/minimum_functionality.md +96 -0
  52. package/skills/app-store-review-agent/references/rules/design/sign_in_with_apple.md +54 -0
  53. package/skills/app-store-review-agent/references/rules/entitlements/unused_entitlements.md +83 -0
  54. package/skills/app-store-review-agent/references/rules/metadata/accurate_metadata.md +54 -0
  55. package/skills/app-store-review-agent/references/rules/metadata/apple_trademark.md +99 -0
  56. package/skills/app-store-review-agent/references/rules/metadata/china_storefront.md +72 -0
  57. package/skills/app-store-review-agent/references/rules/metadata/competitor_terms.md +56 -0
  58. package/skills/app-store-review-agent/references/rules/metadata/subscription_metadata.md +81 -0
  59. package/skills/app-store-review-agent/references/rules/privacy/privacy_manifest.md +84 -0
  60. package/skills/app-store-review-agent/references/rules/privacy/unnecessary_data.md +60 -0
  61. package/skills/app-store-review-agent/references/rules/subscription/misleading_pricing.md +63 -0
  62. package/skills/app-store-review-agent/references/rules/subscription/missing_tos_pp.md +54 -0
  63. package/skills/awf-ponytail/SKILL.md +91 -0
  64. package/skills/awf-ponytail-review/SKILL.md +67 -0
  65. package/skills/awf-session-restore/SKILL.md +3 -3
  66. package/skills/brainstorm-agent/SKILL.md +11 -2
  67. package/skills/brainstorm-agent/templates/brief-template.md +8 -0
  68. package/skills/claude-planner/SKILL.md +47 -0
  69. package/skills/code-review/SKILL.md +87 -0
  70. package/skills/expo-game-development/SKILL.md +163 -0
  71. package/skills/flutter/LICENSE.txt +202 -0
  72. package/skills/flutter/SKILL.md +127 -0
  73. package/skills/flutter-project-creater/LICENSE.txt +202 -0
  74. package/skills/flutter-project-creater/SKILL.md +106 -0
  75. package/skills/game-developer/SKILL.md +163 -0
  76. package/skills/game-developer/references/ecs-patterns.md +501 -0
  77. package/skills/game-developer/references/multiplayer-networking.md +475 -0
  78. package/skills/game-developer/references/performance-optimization.md +422 -0
  79. package/skills/game-developer/references/unity-patterns.md +271 -0
  80. package/skills/game-developer/references/unreal-cpp.md +352 -0
  81. package/skills/generate-gui-assets/SKILL.md +305 -0
  82. package/skills/generate-gui-assets/agents/openai.yaml +4 -0
  83. package/skills/generate-gui-assets/references/catalog-schema.md +58 -0
  84. package/skills/generate-gui-assets/references/extraction-techniques.md +21 -0
  85. package/skills/generate-gui-assets/references/prompt-patterns.md +58 -0
  86. package/skills/generate-gui-assets/scripts/__pycache__/clean_chroma_edges.cpython-311.pyc +0 -0
  87. package/skills/generate-gui-assets/scripts/build_gui_contact_sheet.py +51 -0
  88. package/skills/generate-gui-assets/scripts/clean_chroma_edges.py +262 -0
  89. package/skills/generate-gui-assets/scripts/copy_approved_icons.py +64 -0
  90. package/skills/generate-gui-assets/scripts/prepare_gui_asset_run.py +91 -0
  91. package/skills/generate-gui-assets/scripts/suggest_grid_options.py +63 -0
  92. package/skills/generate-gui-assets/scripts/validate_gui_catalog.py +50 -0
  93. package/skills/godot-game-development/SKILL.md +142 -0
  94. package/skills/hatch-pet/LICENSE.txt +201 -0
  95. package/skills/hatch-pet/SKILL.md +420 -0
  96. package/skills/hatch-pet/agents/openai.yaml +4 -0
  97. package/skills/hatch-pet/references/animation-rows.md +29 -0
  98. package/skills/hatch-pet/references/codex-pet-contract.md +35 -0
  99. package/skills/hatch-pet/references/qa-rubric.md +60 -0
  100. package/skills/hatch-pet/scripts/__pycache__/clean_chroma_edges.cpython-311.pyc +0 -0
  101. package/skills/hatch-pet/scripts/clean_chroma_edges.py +262 -0
  102. package/skills/hatch-pet/scripts/compose_atlas.py +150 -0
  103. package/skills/hatch-pet/scripts/derive_running_left_from_running_right.py +143 -0
  104. package/skills/hatch-pet/scripts/extract_strip_frames.py +323 -0
  105. package/skills/hatch-pet/scripts/finalize_pet_run.py +382 -0
  106. package/skills/hatch-pet/scripts/generate_pet_images.py +287 -0
  107. package/skills/hatch-pet/scripts/inspect_frames.py +246 -0
  108. package/skills/hatch-pet/scripts/make_contact_sheet.py +96 -0
  109. package/skills/hatch-pet/scripts/package_custom_pet.py +108 -0
  110. package/skills/hatch-pet/scripts/pet_job_status.py +117 -0
  111. package/skills/hatch-pet/scripts/prepare_pet_run.py +673 -0
  112. package/skills/hatch-pet/scripts/queue_pet_repairs.py +172 -0
  113. package/skills/hatch-pet/scripts/record_imagegen_result.py +250 -0
  114. package/skills/hatch-pet/scripts/render_animation_videos.py +134 -0
  115. package/skills/hatch-pet/scripts/render_animation_videos.sh +5 -0
  116. package/skills/hatch-pet/scripts/validate_atlas.py +139 -0
  117. package/skills/i18n-orchestrator/SKILL.md +37 -0
  118. package/skills/ios-simulator-skill/SKILL.md +390 -0
  119. package/skills/ios-simulator-skill/scripts/accessibility_audit.py +300 -0
  120. package/skills/ios-simulator-skill/scripts/app_launcher.py +326 -0
  121. package/skills/ios-simulator-skill/scripts/app_state_capture.py +400 -0
  122. package/skills/ios-simulator-skill/scripts/appearance.py +385 -0
  123. package/skills/ios-simulator-skill/scripts/build_and_test.py +348 -0
  124. package/skills/ios-simulator-skill/scripts/clipboard.py +103 -0
  125. package/skills/ios-simulator-skill/scripts/common/__init__.py +61 -0
  126. package/skills/ios-simulator-skill/scripts/common/cache_utils.py +289 -0
  127. package/skills/ios-simulator-skill/scripts/common/device_utils.py +462 -0
  128. package/skills/ios-simulator-skill/scripts/common/env_config.py +35 -0
  129. package/skills/ios-simulator-skill/scripts/common/hang_pipeline.py +862 -0
  130. package/skills/ios-simulator-skill/scripts/common/hang_sessions.py +490 -0
  131. package/skills/ios-simulator-skill/scripts/common/idb_utils.py +180 -0
  132. package/skills/ios-simulator-skill/scripts/common/screenshot_utils.py +338 -0
  133. package/skills/ios-simulator-skill/scripts/container.py +668 -0
  134. package/skills/ios-simulator-skill/scripts/gesture.py +394 -0
  135. package/skills/ios-simulator-skill/scripts/hang_watcher.py +1533 -0
  136. package/skills/ios-simulator-skill/scripts/keyboard.py +391 -0
  137. package/skills/ios-simulator-skill/scripts/localization_audit.py +483 -0
  138. package/skills/ios-simulator-skill/scripts/location.py +467 -0
  139. package/skills/ios-simulator-skill/scripts/log_monitor.py +493 -0
  140. package/skills/ios-simulator-skill/scripts/model_inspector.py +645 -0
  141. package/skills/ios-simulator-skill/scripts/navigator.py +461 -0
  142. package/skills/ios-simulator-skill/scripts/privacy_manager.py +310 -0
  143. package/skills/ios-simulator-skill/scripts/push_notification.py +240 -0
  144. package/skills/ios-simulator-skill/scripts/screen_mapper.py +296 -0
  145. package/skills/ios-simulator-skill/scripts/sim_health_check.sh +245 -0
  146. package/skills/ios-simulator-skill/scripts/sim_list.py +299 -0
  147. package/skills/ios-simulator-skill/scripts/simctl_boot.py +312 -0
  148. package/skills/ios-simulator-skill/scripts/simctl_create.py +316 -0
  149. package/skills/ios-simulator-skill/scripts/simctl_delete.py +357 -0
  150. package/skills/ios-simulator-skill/scripts/simctl_erase.py +351 -0
  151. package/skills/ios-simulator-skill/scripts/simctl_shutdown.py +290 -0
  152. package/skills/ios-simulator-skill/scripts/simulator_selector.py +375 -0
  153. package/skills/ios-simulator-skill/scripts/status_bar.py +250 -0
  154. package/skills/ios-simulator-skill/scripts/test_recorder.py +323 -0
  155. package/skills/ios-simulator-skill/scripts/visual_diff.py +235 -0
  156. package/skills/ios-simulator-skill/scripts/xcode/__init__.py +13 -0
  157. package/skills/ios-simulator-skill/scripts/xcode/builder.py +397 -0
  158. package/skills/ios-simulator-skill/scripts/xcode/cache.py +204 -0
  159. package/skills/ios-simulator-skill/scripts/xcode/config.py +178 -0
  160. package/skills/ios-simulator-skill/scripts/xcode/reporter.py +343 -0
  161. package/skills/ios-simulator-skill/scripts/xcode/xcresult.py +451 -0
  162. package/skills/ios-visual-qa-strategist/SKILL.md +111 -0
  163. package/skills/ios-visual-qa-strategist/agents/openai.yaml +4 -0
  164. package/skills/ios-visual-qa-strategist/references/ios-tool-selection.md +61 -0
  165. package/skills/ios-visual-qa-strategist/references/minimal-capture-policy.md +56 -0
  166. package/skills/ios-visual-qa-strategist/references/visual-reasoning-heuristics.md +53 -0
  167. package/skills/orchestrator/SKILL.md +0 -20
  168. package/skills/persistent-storage/SKILL.md +55 -0
  169. package/skills/short-maker/SKILL.md +23 -0
  170. package/skills/short-maker/scripts/effects.js +56 -0
  171. package/skills/short-maker/scripts/shortmaker-bridge.js +332 -0
  172. package/skills/short-maker/scripts/videomix.js +601 -0
  173. package/skills/short-maker/templates/hyperframes/cinematic-character.template.html +172 -0
  174. package/skills/short-maker/templates/hyperframes/index.template.html +194 -0
  175. package/skills/smali-to-kotlin/SKILL.md +128 -0
  176. package/skills/smali-to-kotlin/examples/getting-started/tech-stack.md +58 -0
  177. package/skills/smali-to-kotlin/examples/pipeline/data-ui-parity.md +118 -0
  178. package/skills/smali-to-kotlin/examples/pipeline/scanner-and-bootstrap.md +106 -0
  179. package/skills/smali-to-kotlin/library-patterns.md +189 -0
  180. package/skills/smali-to-kotlin/phase-0-discovery.md +128 -0
  181. package/skills/smali-to-kotlin/phase-1-architecture.md +166 -0
  182. package/skills/smali-to-kotlin/phase-2-blueprint-ui.md +347 -0
  183. package/skills/smali-to-kotlin/phase-2-blueprint.md +228 -0
  184. package/skills/smali-to-kotlin/phase-3-build.md +248 -0
  185. package/skills/smali-to-kotlin/phase-3-logic-build.md +268 -0
  186. package/skills/smali-to-kotlin/smali-reading-guide.md +310 -0
  187. package/skills/smali-to-kotlin/templates/app-map.md +101 -0
  188. package/skills/smali-to-kotlin/templates/architecture.md +142 -0
  189. package/skills/smali-to-kotlin/templates/blueprint.md +145 -0
  190. package/skills/spec-gate/SKILL.md +6 -2
  191. package/skills/symphony-enforcer/SKILL.md +8 -0
  192. package/skills/symphony-enforcer/examples/mindful-stop.md +2 -0
  193. package/skills/symphony-enforcer/examples/three-phase.md +16 -0
  194. package/skills/symphony-enforcer/examples/trigger-points.md +7 -1
  195. package/skills/unity-game-development/SKILL.md +231 -0
  196. package/skills/video-edit/SKILL.md +36 -0
  197. package/skills/video-edit/scripts/video_edit.py +324 -0
  198. package/templates/project-identity/android.json +2 -2
  199. package/templates/project-identity/backend-nestjs.json +2 -2
  200. package/templates/project-identity/expo.json +2 -2
  201. package/templates/project-identity/ios.json +2 -2
  202. package/templates/project-identity/web-nextjs.json +2 -2
  203. package/templates/setup-mapping.json +48 -0
  204. package/templates/specs/design-template.md +161 -71
  205. package/templates/specs/requirements-template.md +65 -133
  206. package/templates/specs/task-spec-template.xml +3 -0
  207. package/workflows/_uncategorized/critic.md +40 -0
  208. package/workflows/_uncategorized/git-rebase-flow.md +81 -0
  209. package/workflows/_uncategorized/image-gen.md +118 -0
  210. package/workflows/_uncategorized/multi-model-pipeline.md +60 -0
  211. package/workflows/_uncategorized/pixel-gen.md +86 -0
  212. package/workflows/_uncategorized/pixel-setup.md +90 -0
  213. package/workflows/_uncategorized/ponytail-review.md +59 -0
  214. package/workflows/_uncategorized/reverse-android-build.md +222 -0
  215. package/workflows/_uncategorized/reverse-android-design.md +139 -0
  216. package/workflows/_uncategorized/reverse-android-discover.md +150 -0
  217. package/workflows/_uncategorized/reverse-android-scan.md +158 -0
  218. package/workflows/_uncategorized/reverse-android.md +143 -0
  219. package/workflows/_uncategorized/reverse-ios-build.md +240 -0
  220. package/workflows/_uncategorized/reverse-ios-design.md +112 -0
  221. package/workflows/_uncategorized/reverse-ios-discover.md +120 -0
  222. package/workflows/_uncategorized/reverse-ios-scan.md +155 -0
  223. package/workflows/_uncategorized/reverse-ios.md +152 -0
  224. package/workflows/_uncategorized/safety-router.md +34 -0
  225. package/workflows/_uncategorized/teach.md +89 -0
  226. package/workflows/_uncategorized/verify-ui.md +53 -0
  227. package/workflows/_uncategorized/visualize-screenshots.md +34 -0
  228. package/workflows/ads/ads-analyst.md +201 -0
  229. package/workflows/ads/ads-audit.md +106 -0
  230. package/workflows/ads/ads-optimize.md +97 -0
  231. package/workflows/ads/ads-targeting.md +241 -0
  232. package/workflows/ads/adsExpert.md +160 -0
  233. package/workflows/ads/smali-ads-config.md +400 -0
  234. package/workflows/ads/smali-ads-flow.md +331 -0
  235. package/workflows/ads/smali-ads-interstitial.md +377 -0
  236. package/workflows/ads/smali-ads-native.md +382 -0
  237. package/workflows/context/teach.md +89 -0
  238. package/workflows/gitnexus.md +8 -8
  239. package/workflows/lifecycle/brainstorm.md +43 -0
  240. package/workflows/lifecycle/code.md +5 -0
  241. package/workflows/lifecycle/init.md +23 -5
  242. package/workflows/lifecycle/multi-model-pipeline.md +60 -0
  243. package/workflows/quality/ponytail-review.md +59 -0
  244. package/workflows/roles/critic.md +40 -0
  245. package/workflows/roles/safety-router.md +34 -0
@@ -0,0 +1,483 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ iOS Localization Catalog Audit
4
+
5
+ Parses .xcstrings (JSON) or .strings/.stringsdict (plist) catalogs and reports:
6
+ - Per-locale missing/untranslated keys
7
+ - Keys in source but absent from catalog (missing)
8
+ - Keys in catalog but absent from source (unused)
9
+ - Format-specifier placeholder mismatches across locales
10
+
11
+ Pure file analysis — no simulator interaction required.
12
+
13
+ Usage:
14
+ python scripts/localization_audit.py --catalog Localizable.xcstrings
15
+ python scripts/localization_audit.py --catalog Localizable.xcstrings --source ./MyApp
16
+ python scripts/localization_audit.py --catalog Localizable.xcstrings --strict
17
+ """
18
+
19
+ import argparse
20
+ import json
21
+ import plistlib
22
+ import re
23
+ import sys
24
+ from dataclasses import asdict, dataclass, field
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ # === TYPES ===
29
+
30
+
31
+ @dataclass
32
+ class LocaleGap:
33
+ """A key that is missing or needs review in a specific locale."""
34
+
35
+ key: str
36
+ locale: str
37
+ reason: str # "missing", "needs_review", "new", "stale"
38
+
39
+
40
+ @dataclass
41
+ class PlaceholderMismatch:
42
+ """A key where placeholder counts differ across locales."""
43
+
44
+ key: str
45
+ source_locale: str
46
+ source_placeholders: list[str]
47
+ offending_locale: str
48
+ offending_placeholders: list[str]
49
+
50
+
51
+ @dataclass
52
+ class AuditReport:
53
+ """Structured result of a full catalog audit."""
54
+
55
+ catalog_path: str
56
+ source_language: str
57
+ total_keys: int
58
+ locales: list[str]
59
+ gaps: list[LocaleGap] = field(default_factory=list)
60
+ missing_from_catalog: list[str] = field(default_factory=list) # in source, not catalog
61
+ unused_in_source: list[str] = field(default_factory=list) # in catalog, not source
62
+ placeholder_mismatches: list[PlaceholderMismatch] = field(default_factory=list)
63
+
64
+ def has_findings(self) -> bool:
65
+ return bool(
66
+ self.gaps
67
+ or self.missing_from_catalog
68
+ or self.unused_in_source
69
+ or self.placeholder_mismatches
70
+ )
71
+
72
+ def to_dict(self) -> dict[str, Any]:
73
+ return asdict(self)
74
+
75
+
76
+ # === PLACEHOLDER EXTRACTION ===
77
+
78
+ # Matches %d, %@, %s, %lld, %ld, %f, %g, %i, %u and positional %1$@, %2$d etc.
79
+ # Length-modifier group (hh|h|ll|l|z|t|q) handles %lld, %lu, %ld, %hhu etc.
80
+ _PLACEHOLDER_RE = re.compile(
81
+ r"%(?:\d+\$)?(?:[-+0 #]*)?(?:\d+)?(?:\.\d+)?(?:hh|h|ll|l|z|t|q)?[diouxXeEfgGcsSpaAqQzZtb@]"
82
+ )
83
+
84
+ # Swift source patterns for localized strings
85
+ _SWIFT_LOCALIZED_RE = re.compile(
86
+ r'String\s*\(\s*localized\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"|'
87
+ r'NSLocalizedString\s*\(\s*"([^"\\]*(?:\\.[^"\\]*)*)"'
88
+ )
89
+
90
+
91
+ def _extract_placeholders(value: str) -> list[str]:
92
+ """Extract all format specifiers from a string value."""
93
+ return _PLACEHOLDER_RE.findall(value)
94
+
95
+
96
+ # === XCSTRINGS PARSER ===
97
+
98
+
99
+ def _parse_xcstrings(catalog_path: Path) -> tuple[str, dict[str, dict[str, Any]]]:
100
+ """
101
+ Parse an .xcstrings JSON catalog.
102
+
103
+ Returns:
104
+ (source_language, strings_dict)
105
+ strings_dict maps key -> {locale -> {"state": ..., "value": ...}}
106
+ """
107
+ try:
108
+ raw = json.loads(catalog_path.read_text(encoding="utf-8"))
109
+ except json.JSONDecodeError as exc:
110
+ raise ValueError(f"Invalid JSON in {catalog_path}: {exc}") from exc
111
+
112
+ source_language: str = raw.get("sourceLanguage", "en")
113
+ raw_strings: dict = raw.get("strings", {})
114
+
115
+ strings: dict[str, dict[str, Any]] = {}
116
+ for key, entry in raw_strings.items():
117
+ localizations: dict = entry.get("localizations", {})
118
+ strings[key] = {}
119
+ for locale, loc_data in localizations.items():
120
+ if "stringUnit" in loc_data:
121
+ unit = loc_data["stringUnit"]
122
+ strings[key][locale] = {
123
+ "state": unit.get("state", ""),
124
+ "value": unit.get("value", ""),
125
+ }
126
+ elif "variations" in loc_data:
127
+ # Plural variations — grab first available unit for placeholder check
128
+ variations = loc_data["variations"]
129
+ plural = variations.get("plural", {})
130
+ first_variant = next(iter(plural.values()), {})
131
+ unit = first_variant.get("stringUnit", {})
132
+ strings[key][locale] = {
133
+ "state": unit.get("state", ""),
134
+ "value": unit.get("value", ""),
135
+ }
136
+
137
+ return source_language, strings
138
+
139
+
140
+ # === LEGACY .strings / .stringsdict PARSER ===
141
+
142
+
143
+ def _parse_strings_file(catalog_path: Path) -> tuple[str, dict[str, dict[str, Any]]]:
144
+ """
145
+ Parse a legacy .strings or .stringsdict file via plistlib.
146
+
147
+ .strings files are treated as a single-locale catalog whose locale is
148
+ inferred from the path (e.g. en.lproj/Localizable.strings → "en").
149
+ Returns a minimal structure compatible with the audit logic.
150
+ """
151
+ # Attempt to infer locale from parent directory (e.g. "en.lproj")
152
+ parent = catalog_path.parent.name
153
+ locale = parent.removesuffix(".lproj") if parent.endswith(".lproj") else "unknown"
154
+ source_language = locale
155
+
156
+ suffix = catalog_path.suffix.lower()
157
+
158
+ if suffix == ".stringsdict":
159
+ try:
160
+ data = plistlib.loads(catalog_path.read_bytes())
161
+ except Exception as exc:
162
+ raise ValueError(f"Failed to parse .stringsdict {catalog_path}: {exc}") from exc
163
+ # Each key maps to a plural rule dict — extract NSStringLocalizedFormatKey as value
164
+ strings: dict[str, dict[str, Any]] = {}
165
+ for key, plural_dict in data.items():
166
+ value = plural_dict.get("NSStringLocalizedFormatKey", "")
167
+ strings[key] = {locale: {"state": "translated", "value": value}}
168
+ return source_language, strings
169
+
170
+ # .strings — may be binary or XML plist, or legacy =; format
171
+ plist_error: Exception | None = None
172
+ try:
173
+ data = plistlib.loads(catalog_path.read_bytes())
174
+ strings = {k: {locale: {"state": "translated", "value": v}} for k, v in data.items()}
175
+ return source_language, strings
176
+ except Exception as exc:
177
+ plist_error = exc
178
+
179
+ # Fallback: text-format .strings ("key" = "value";)
180
+ _kv_re = re.compile(r'"((?:[^"\\]|\\.)*)"\s*=\s*"((?:[^"\\]|\\.)*)"', re.MULTILINE)
181
+ text_error: Exception | None = None
182
+ try:
183
+ try:
184
+ text = catalog_path.read_text(encoding="utf-8")
185
+ except UnicodeDecodeError as exc:
186
+ text_error = exc
187
+ text = catalog_path.read_text(encoding="latin-1")
188
+ strings = {}
189
+ for match in _kv_re.finditer(text):
190
+ k, v = match.group(1), match.group(2)
191
+ strings[k] = {locale: {"state": "translated", "value": v}}
192
+ return source_language, strings
193
+ except Exception as exc:
194
+ if text_error is None:
195
+ text_error = exc
196
+
197
+ raise ValueError(
198
+ f"Failed to parse .strings file {catalog_path}: "
199
+ f"plist error: {plist_error}; text-decode error: {text_error}"
200
+ )
201
+
202
+
203
+ # === SWIFT SOURCE SCANNER ===
204
+
205
+
206
+ def _scan_swift_sources(source_dir: Path) -> set[str]:
207
+ """
208
+ Scan *.swift files under source_dir for localized string keys.
209
+
210
+ Matches:
211
+ String(localized: "key")
212
+ NSLocalizedString("key", ...)
213
+
214
+ Note: regex-based for v1 — swift-syntax AST parsing would be more robust
215
+ for multiline literals and string interpolation edge-cases.
216
+ """
217
+ keys: set[str] = set()
218
+ for swift_file in source_dir.rglob("*.swift"):
219
+ try:
220
+ content = swift_file.read_text(encoding="utf-8")
221
+ except OSError:
222
+ continue
223
+ for match in _SWIFT_LOCALIZED_RE.finditer(content):
224
+ key = match.group(1) or match.group(2)
225
+ if key:
226
+ keys.add(key)
227
+ return keys
228
+
229
+
230
+ # === CORE AUDITOR ===
231
+
232
+
233
+ class LocalizationAuditor:
234
+ """Audits an .xcstrings or legacy .strings catalog for localization gaps."""
235
+
236
+ def __init__(self, catalog_path: Path, source_dir: Path | None = None):
237
+ self.catalog_path = catalog_path
238
+ self.source_dir = source_dir
239
+
240
+ def _load_catalog(self) -> tuple[str, dict[str, dict[str, Any]]]:
241
+ suffix = self.catalog_path.suffix.lower()
242
+ if suffix == ".xcstrings":
243
+ return _parse_xcstrings(self.catalog_path)
244
+ if suffix in {".strings", ".stringsdict"}:
245
+ return _parse_strings_file(self.catalog_path)
246
+ raise ValueError(
247
+ f"Unsupported catalog format '{suffix}'. "
248
+ "Expected .xcstrings, .strings, or .stringsdict."
249
+ )
250
+
251
+ def _collect_gaps(
252
+ self,
253
+ strings: dict[str, dict[str, Any]],
254
+ all_locales: set[str],
255
+ source_language: str,
256
+ ) -> list[LocaleGap]:
257
+ """Find keys with missing or needs-review translations per locale."""
258
+ gaps: list[LocaleGap] = []
259
+ non_source_locales = all_locales - {source_language}
260
+
261
+ for key, locale_map in strings.items():
262
+ for locale in non_source_locales:
263
+ if locale not in locale_map:
264
+ gaps.append(LocaleGap(key=key, locale=locale, reason="missing"))
265
+ else:
266
+ state = locale_map[locale].get("state", "")
267
+ if state in {"needs_review", "new", "stale"}:
268
+ gaps.append(LocaleGap(key=key, locale=locale, reason=state))
269
+
270
+ return sorted(gaps, key=lambda g: (g.locale, g.key))
271
+
272
+ def _check_placeholder_mismatches(
273
+ self,
274
+ strings: dict[str, dict[str, Any]],
275
+ source_language: str,
276
+ ) -> list[PlaceholderMismatch]:
277
+ """Verify placeholder counts match source language for every locale."""
278
+ mismatches: list[PlaceholderMismatch] = []
279
+
280
+ for key, locale_map in strings.items():
281
+ source_entry = locale_map.get(source_language)
282
+ if not source_entry:
283
+ continue
284
+ source_value = source_entry.get("value", "")
285
+ source_placeholders = _extract_placeholders(source_value)
286
+
287
+ for locale, entry in locale_map.items():
288
+ if locale == source_language:
289
+ continue
290
+ value = entry.get("value", "")
291
+ if not value:
292
+ continue # gaps already reported separately
293
+ locale_placeholders = _extract_placeholders(value)
294
+ # Note: only count is compared; positional-type swaps not detected
295
+ if len(locale_placeholders) != len(source_placeholders):
296
+ mismatches.append(
297
+ PlaceholderMismatch(
298
+ key=key,
299
+ source_locale=source_language,
300
+ source_placeholders=source_placeholders,
301
+ offending_locale=locale,
302
+ offending_placeholders=locale_placeholders,
303
+ )
304
+ )
305
+
306
+ return sorted(mismatches, key=lambda m: (m.offending_locale, m.key))
307
+
308
+ def audit(self) -> AuditReport:
309
+ """Run the full audit and return a structured report."""
310
+ source_language, strings = self._load_catalog()
311
+
312
+ all_locales: set[str] = set()
313
+ for locale_map in strings.values():
314
+ all_locales.update(locale_map.keys())
315
+
316
+ report = AuditReport(
317
+ catalog_path=str(self.catalog_path),
318
+ source_language=source_language,
319
+ total_keys=len(strings),
320
+ locales=sorted(all_locales),
321
+ )
322
+
323
+ report.gaps = self._collect_gaps(strings, all_locales, source_language)
324
+ report.placeholder_mismatches = self._check_placeholder_mismatches(strings, source_language)
325
+
326
+ if self.source_dir:
327
+ source_keys = _scan_swift_sources(self.source_dir)
328
+ catalog_keys = set(strings.keys())
329
+ report.missing_from_catalog = sorted(source_keys - catalog_keys)
330
+ report.unused_in_source = sorted(catalog_keys - source_keys)
331
+
332
+ return report
333
+
334
+
335
+ # === OUTPUT FORMATTING ===
336
+
337
+
338
+ def _format_default(report: AuditReport) -> str:
339
+ """Compact summary — 3-5 lines."""
340
+ gap_by_locale: dict[str, int] = {}
341
+ for gap in report.gaps:
342
+ gap_by_locale[gap.locale] = gap_by_locale.get(gap.locale, 0) + 1
343
+
344
+ gap_summary = ", ".join(
345
+ f"{count} in {locale}" for locale, count in sorted(gap_by_locale.items())
346
+ )
347
+ if not gap_summary:
348
+ gap_summary = "none"
349
+
350
+ lines = [
351
+ f"Catalog: {report.total_keys} keys, {len(report.locales)} locales, "
352
+ f"{len(report.gaps)} gaps.",
353
+ f"Missing/needs-review: {gap_summary}.",
354
+ ]
355
+
356
+ if report.missing_from_catalog:
357
+ lines.append(f"Missing from catalog: {len(report.missing_from_catalog)} keys.")
358
+ if report.unused_in_source:
359
+ lines.append(f"Unused in source: {len(report.unused_in_source)} keys.")
360
+ if report.placeholder_mismatches:
361
+ lines.append(f"Placeholder mismatches: {len(report.placeholder_mismatches)}.")
362
+
363
+ if not report.has_findings():
364
+ lines.append("No issues found.")
365
+
366
+ return "\n".join(lines)
367
+
368
+
369
+ def _format_verbose(report: AuditReport) -> str:
370
+ """Detailed listing of every finding."""
371
+ sections: list[str] = [_format_default(report), ""]
372
+
373
+ if report.gaps:
374
+ sections.append("=== Translation Gaps ===")
375
+ current_locale = None
376
+ for gap in report.gaps:
377
+ if gap.locale != current_locale:
378
+ sections.append(f"\n[{gap.locale}]")
379
+ current_locale = gap.locale
380
+ sections.append(f" {gap.reason:15s} {gap.key}")
381
+
382
+ if report.missing_from_catalog:
383
+ sections.append("\n=== Keys in Source, Missing from Catalog ===")
384
+ for key in report.missing_from_catalog:
385
+ sections.append(f" {key}")
386
+
387
+ if report.unused_in_source:
388
+ sections.append("\n=== Keys in Catalog, Unused in Source ===")
389
+ for key in report.unused_in_source:
390
+ sections.append(f" {key}")
391
+
392
+ if report.placeholder_mismatches:
393
+ sections.append("\n=== Placeholder Mismatches ===")
394
+ for m in report.placeholder_mismatches:
395
+ sections.append(
396
+ f" [{m.offending_locale}] {m.key}\n"
397
+ f" {m.source_locale}: {m.source_placeholders}\n"
398
+ f" {m.offending_locale}: {m.offending_placeholders}"
399
+ )
400
+
401
+ return "\n".join(sections)
402
+
403
+
404
+ # === CLI ===
405
+
406
+
407
+ def _build_parser() -> argparse.ArgumentParser:
408
+ parser = argparse.ArgumentParser(
409
+ description="Audit .xcstrings / .strings catalogs for localization gaps.",
410
+ formatter_class=argparse.RawDescriptionHelpFormatter,
411
+ epilog="""
412
+ Examples:
413
+ python scripts/localization_audit.py --catalog Localizable.xcstrings
414
+ python scripts/localization_audit.py --catalog App.xcstrings --source ./MyApp
415
+ python scripts/localization_audit.py --catalog App.xcstrings --strict --json
416
+ """,
417
+ )
418
+ parser.add_argument(
419
+ "--catalog",
420
+ required=True,
421
+ type=Path,
422
+ metavar="PATH",
423
+ help="Path to .xcstrings, .strings, or .stringsdict catalog file.",
424
+ )
425
+ parser.add_argument(
426
+ "--source",
427
+ type=Path,
428
+ metavar="DIR",
429
+ help="Swift source root for unused/missing key cross-reference.",
430
+ )
431
+ parser.add_argument(
432
+ "--strict",
433
+ action="store_true",
434
+ help="Exit with non-zero status if any findings are present.",
435
+ )
436
+ parser.add_argument(
437
+ "--json",
438
+ action="store_true",
439
+ dest="json_output",
440
+ help="Output structured JSON report.",
441
+ )
442
+ parser.add_argument(
443
+ "--verbose",
444
+ action="store_true",
445
+ help="List every gap, unused key, and placeholder mismatch.",
446
+ )
447
+ return parser
448
+
449
+
450
+ def main() -> None:
451
+ parser = _build_parser()
452
+ args = parser.parse_args()
453
+
454
+ catalog_path: Path = args.catalog
455
+ if not catalog_path.exists():
456
+ print(f"Error: catalog not found: {catalog_path}", file=sys.stderr)
457
+ sys.exit(1)
458
+
459
+ source_dir: Path | None = args.source
460
+ if source_dir is not None and not source_dir.is_dir():
461
+ print(f"Error: source directory not found: {source_dir}", file=sys.stderr)
462
+ sys.exit(1)
463
+
464
+ try:
465
+ auditor = LocalizationAuditor(catalog_path=catalog_path, source_dir=source_dir)
466
+ report = auditor.audit()
467
+ except ValueError as exc:
468
+ print(f"Error: {exc}", file=sys.stderr)
469
+ sys.exit(1)
470
+
471
+ if args.json_output:
472
+ print(json.dumps(report.to_dict(), indent=2))
473
+ elif args.verbose:
474
+ print(_format_verbose(report))
475
+ else:
476
+ print(_format_default(report))
477
+
478
+ if args.strict and report.has_findings():
479
+ sys.exit(2)
480
+
481
+
482
+ if __name__ == "__main__":
483
+ main()