@leejungkiin/awkit 1.7.0 → 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.
- package/bin/awk.js +576 -84
- package/core/CLAUDE.md +1 -1
- package/core/GEMINI.md +148 -167
- package/core/GEMINI.md.bak +149 -116
- package/core/skill-runtime-manifest.json +3 -0
- package/docs/Claude Fable 5.md +3826 -0
- package/docs/android_kotlin_system_instruction.md +210 -0
- package/docs/brainstorm_ponytail_integration.md +146 -0
- package/docs/brainstorm_smart_setup.md +113 -0
- package/docs/deep-research-report (1).md +293 -0
- package/docs/history/GEMINI.v1.md +135 -0
- package/docs/history/brainstorm_antigravity_unified_architecture.v1.md +105 -0
- package/docs/history/implementation_plan.v1.md +58 -0
- package/package.json +4 -1
- package/scripts/artifact-storage.js +130 -0
- package/scripts/automation-gate.js +40 -7
- package/scripts/claude-plan.js +76 -0
- package/scripts/dependency-manager.js +210 -0
- package/scripts/exec-rtk.js +11 -5
- package/scripts/i18n-helper.js +381 -0
- package/scripts/multi-model-pipeline.js +144 -0
- package/skill-packs/mobile-ios/pack.json +4 -2
- package/skill-packs/reverse-engineering/pack.json +1 -0
- package/skills/CATALOG.md +20 -0
- package/skills/GEMINI.md +9 -1
- package/skills/TRIGGER_INDEX.md +10 -0
- package/skills/ai-music/SKILL.md +275 -0
- package/skills/android-re-analyzer/SKILL.md +238 -0
- package/skills/android-re-analyzer/references/api-extraction-patterns.md +119 -0
- package/skills/android-re-analyzer/references/call-flow-analysis.md +176 -0
- package/skills/android-re-analyzer/references/fernflower-usage.md +115 -0
- package/skills/android-re-analyzer/references/jadx-usage.md +116 -0
- package/skills/android-re-analyzer/references/setup-guide.md +221 -0
- package/skills/android-re-analyzer/scripts/check-deps.sh +129 -0
- package/skills/android-re-analyzer/scripts/decompile.sh +375 -0
- package/skills/android-re-analyzer/scripts/find-api-calls.sh +118 -0
- package/skills/android-re-analyzer/scripts/install-dep.sh +448 -0
- package/skills/animal-island-ui-style/SKILL.md +1450 -0
- package/skills/app-store-review-agent/SKILL.md +164 -0
- package/skills/app-store-review-agent/references/guidelines/README.md +154 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/ai_apps.md +37 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/all_apps.md +50 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/crypto_finance.md +31 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/games.md +31 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/health_fitness.md +31 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/kids.md +27 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/macos.md +38 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/social_ugc.md +32 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/subscription_iap.md +34 -0
- package/skills/app-store-review-agent/references/guidelines/by-app-type/vpn.md +18 -0
- package/skills/app-store-review-agent/references/rules/design/minimum_functionality.md +96 -0
- package/skills/app-store-review-agent/references/rules/design/sign_in_with_apple.md +54 -0
- package/skills/app-store-review-agent/references/rules/entitlements/unused_entitlements.md +83 -0
- package/skills/app-store-review-agent/references/rules/metadata/accurate_metadata.md +54 -0
- package/skills/app-store-review-agent/references/rules/metadata/apple_trademark.md +99 -0
- package/skills/app-store-review-agent/references/rules/metadata/china_storefront.md +72 -0
- package/skills/app-store-review-agent/references/rules/metadata/competitor_terms.md +56 -0
- package/skills/app-store-review-agent/references/rules/metadata/subscription_metadata.md +81 -0
- package/skills/app-store-review-agent/references/rules/privacy/privacy_manifest.md +84 -0
- package/skills/app-store-review-agent/references/rules/privacy/unnecessary_data.md +60 -0
- package/skills/app-store-review-agent/references/rules/subscription/misleading_pricing.md +63 -0
- package/skills/app-store-review-agent/references/rules/subscription/missing_tos_pp.md +54 -0
- package/skills/awf-ponytail/SKILL.md +91 -0
- package/skills/awf-ponytail-review/SKILL.md +67 -0
- package/skills/awf-session-restore/SKILL.md +3 -3
- package/skills/brainstorm-agent/SKILL.md +11 -2
- package/skills/brainstorm-agent/templates/brief-template.md +8 -0
- package/skills/claude-planner/SKILL.md +47 -0
- package/skills/code-review/SKILL.md +87 -0
- package/skills/expo-game-development/SKILL.md +163 -0
- package/skills/flutter/LICENSE.txt +202 -0
- package/skills/flutter/SKILL.md +127 -0
- package/skills/flutter-project-creater/LICENSE.txt +202 -0
- package/skills/flutter-project-creater/SKILL.md +106 -0
- package/skills/game-developer/SKILL.md +163 -0
- package/skills/game-developer/references/ecs-patterns.md +501 -0
- package/skills/game-developer/references/multiplayer-networking.md +475 -0
- package/skills/game-developer/references/performance-optimization.md +422 -0
- package/skills/game-developer/references/unity-patterns.md +271 -0
- package/skills/game-developer/references/unreal-cpp.md +352 -0
- package/skills/generate-gui-assets/SKILL.md +305 -0
- package/skills/generate-gui-assets/agents/openai.yaml +4 -0
- package/skills/generate-gui-assets/references/catalog-schema.md +58 -0
- package/skills/generate-gui-assets/references/extraction-techniques.md +21 -0
- package/skills/generate-gui-assets/references/prompt-patterns.md +58 -0
- package/skills/generate-gui-assets/scripts/__pycache__/clean_chroma_edges.cpython-311.pyc +0 -0
- package/skills/generate-gui-assets/scripts/build_gui_contact_sheet.py +51 -0
- package/skills/generate-gui-assets/scripts/clean_chroma_edges.py +262 -0
- package/skills/generate-gui-assets/scripts/copy_approved_icons.py +64 -0
- package/skills/generate-gui-assets/scripts/prepare_gui_asset_run.py +91 -0
- package/skills/generate-gui-assets/scripts/suggest_grid_options.py +63 -0
- package/skills/generate-gui-assets/scripts/validate_gui_catalog.py +50 -0
- package/skills/godot-game-development/SKILL.md +142 -0
- package/skills/hatch-pet/LICENSE.txt +201 -0
- package/skills/hatch-pet/SKILL.md +420 -0
- package/skills/hatch-pet/agents/openai.yaml +4 -0
- package/skills/hatch-pet/references/animation-rows.md +29 -0
- package/skills/hatch-pet/references/codex-pet-contract.md +35 -0
- package/skills/hatch-pet/references/qa-rubric.md +60 -0
- package/skills/hatch-pet/scripts/__pycache__/clean_chroma_edges.cpython-311.pyc +0 -0
- package/skills/hatch-pet/scripts/clean_chroma_edges.py +262 -0
- package/skills/hatch-pet/scripts/compose_atlas.py +150 -0
- package/skills/hatch-pet/scripts/derive_running_left_from_running_right.py +143 -0
- package/skills/hatch-pet/scripts/extract_strip_frames.py +323 -0
- package/skills/hatch-pet/scripts/finalize_pet_run.py +382 -0
- package/skills/hatch-pet/scripts/generate_pet_images.py +287 -0
- package/skills/hatch-pet/scripts/inspect_frames.py +246 -0
- package/skills/hatch-pet/scripts/make_contact_sheet.py +96 -0
- package/skills/hatch-pet/scripts/package_custom_pet.py +108 -0
- package/skills/hatch-pet/scripts/pet_job_status.py +117 -0
- package/skills/hatch-pet/scripts/prepare_pet_run.py +673 -0
- package/skills/hatch-pet/scripts/queue_pet_repairs.py +172 -0
- package/skills/hatch-pet/scripts/record_imagegen_result.py +250 -0
- package/skills/hatch-pet/scripts/render_animation_videos.py +134 -0
- package/skills/hatch-pet/scripts/render_animation_videos.sh +5 -0
- package/skills/hatch-pet/scripts/validate_atlas.py +139 -0
- package/skills/i18n-orchestrator/SKILL.md +37 -0
- package/skills/ios-simulator-skill/SKILL.md +390 -0
- package/skills/ios-simulator-skill/scripts/accessibility_audit.py +300 -0
- package/skills/ios-simulator-skill/scripts/app_launcher.py +326 -0
- package/skills/ios-simulator-skill/scripts/app_state_capture.py +400 -0
- package/skills/ios-simulator-skill/scripts/appearance.py +385 -0
- package/skills/ios-simulator-skill/scripts/build_and_test.py +348 -0
- package/skills/ios-simulator-skill/scripts/clipboard.py +103 -0
- package/skills/ios-simulator-skill/scripts/common/__init__.py +61 -0
- package/skills/ios-simulator-skill/scripts/common/cache_utils.py +289 -0
- package/skills/ios-simulator-skill/scripts/common/device_utils.py +462 -0
- package/skills/ios-simulator-skill/scripts/common/env_config.py +35 -0
- package/skills/ios-simulator-skill/scripts/common/hang_pipeline.py +862 -0
- package/skills/ios-simulator-skill/scripts/common/hang_sessions.py +490 -0
- package/skills/ios-simulator-skill/scripts/common/idb_utils.py +180 -0
- package/skills/ios-simulator-skill/scripts/common/screenshot_utils.py +338 -0
- package/skills/ios-simulator-skill/scripts/container.py +668 -0
- package/skills/ios-simulator-skill/scripts/gesture.py +394 -0
- package/skills/ios-simulator-skill/scripts/hang_watcher.py +1533 -0
- package/skills/ios-simulator-skill/scripts/keyboard.py +391 -0
- package/skills/ios-simulator-skill/scripts/localization_audit.py +483 -0
- package/skills/ios-simulator-skill/scripts/location.py +467 -0
- package/skills/ios-simulator-skill/scripts/log_monitor.py +493 -0
- package/skills/ios-simulator-skill/scripts/model_inspector.py +645 -0
- package/skills/ios-simulator-skill/scripts/navigator.py +461 -0
- package/skills/ios-simulator-skill/scripts/privacy_manager.py +310 -0
- package/skills/ios-simulator-skill/scripts/push_notification.py +240 -0
- package/skills/ios-simulator-skill/scripts/screen_mapper.py +296 -0
- package/skills/ios-simulator-skill/scripts/sim_health_check.sh +245 -0
- package/skills/ios-simulator-skill/scripts/sim_list.py +299 -0
- package/skills/ios-simulator-skill/scripts/simctl_boot.py +312 -0
- package/skills/ios-simulator-skill/scripts/simctl_create.py +316 -0
- package/skills/ios-simulator-skill/scripts/simctl_delete.py +357 -0
- package/skills/ios-simulator-skill/scripts/simctl_erase.py +351 -0
- package/skills/ios-simulator-skill/scripts/simctl_shutdown.py +290 -0
- package/skills/ios-simulator-skill/scripts/simulator_selector.py +375 -0
- package/skills/ios-simulator-skill/scripts/status_bar.py +250 -0
- package/skills/ios-simulator-skill/scripts/test_recorder.py +323 -0
- package/skills/ios-simulator-skill/scripts/visual_diff.py +235 -0
- package/skills/ios-simulator-skill/scripts/xcode/__init__.py +13 -0
- package/skills/ios-simulator-skill/scripts/xcode/builder.py +397 -0
- package/skills/ios-simulator-skill/scripts/xcode/cache.py +204 -0
- package/skills/ios-simulator-skill/scripts/xcode/config.py +178 -0
- package/skills/ios-simulator-skill/scripts/xcode/reporter.py +343 -0
- package/skills/ios-simulator-skill/scripts/xcode/xcresult.py +451 -0
- package/skills/ios-visual-qa-strategist/SKILL.md +111 -0
- package/skills/ios-visual-qa-strategist/agents/openai.yaml +4 -0
- package/skills/ios-visual-qa-strategist/references/ios-tool-selection.md +61 -0
- package/skills/ios-visual-qa-strategist/references/minimal-capture-policy.md +56 -0
- package/skills/ios-visual-qa-strategist/references/visual-reasoning-heuristics.md +53 -0
- package/skills/orchestrator/SKILL.md +0 -20
- package/skills/persistent-storage/SKILL.md +55 -0
- package/skills/short-maker/SKILL.md +23 -0
- package/skills/short-maker/scripts/effects.js +56 -0
- package/skills/short-maker/scripts/shortmaker-bridge.js +332 -0
- package/skills/short-maker/scripts/videomix.js +601 -0
- package/skills/short-maker/templates/hyperframes/cinematic-character.template.html +172 -0
- package/skills/short-maker/templates/hyperframes/index.template.html +194 -0
- package/skills/smali-to-kotlin/SKILL.md +128 -0
- package/skills/smali-to-kotlin/examples/getting-started/tech-stack.md +58 -0
- package/skills/smali-to-kotlin/examples/pipeline/data-ui-parity.md +118 -0
- package/skills/smali-to-kotlin/examples/pipeline/scanner-and-bootstrap.md +106 -0
- package/skills/smali-to-kotlin/library-patterns.md +189 -0
- package/skills/smali-to-kotlin/phase-0-discovery.md +128 -0
- package/skills/smali-to-kotlin/phase-1-architecture.md +166 -0
- package/skills/smali-to-kotlin/phase-2-blueprint-ui.md +347 -0
- package/skills/smali-to-kotlin/phase-2-blueprint.md +228 -0
- package/skills/smali-to-kotlin/phase-3-build.md +248 -0
- package/skills/smali-to-kotlin/phase-3-logic-build.md +268 -0
- package/skills/smali-to-kotlin/smali-reading-guide.md +310 -0
- package/skills/smali-to-kotlin/templates/app-map.md +101 -0
- package/skills/smali-to-kotlin/templates/architecture.md +142 -0
- package/skills/smali-to-kotlin/templates/blueprint.md +145 -0
- package/skills/spec-gate/SKILL.md +6 -2
- package/skills/symphony-enforcer/SKILL.md +8 -0
- package/skills/symphony-enforcer/examples/mindful-stop.md +2 -0
- package/skills/symphony-enforcer/examples/three-phase.md +16 -0
- package/skills/symphony-enforcer/examples/trigger-points.md +7 -1
- package/skills/unity-game-development/SKILL.md +231 -0
- package/skills/verification-gate/SKILL.md +4 -2
- package/skills/video-edit/SKILL.md +36 -0
- package/skills/video-edit/scripts/video_edit.py +324 -0
- package/templates/setup-mapping.json +48 -0
- package/templates/specs/design-template.md +161 -71
- package/templates/specs/requirements-template.md +65 -133
- package/templates/specs/task-spec-template.xml +3 -0
- package/workflows/_uncategorized/critic.md +40 -0
- package/workflows/_uncategorized/git-rebase-flow.md +81 -0
- package/workflows/_uncategorized/image-gen.md +118 -0
- package/workflows/_uncategorized/multi-model-pipeline.md +60 -0
- package/workflows/_uncategorized/pixel-gen.md +86 -0
- package/workflows/_uncategorized/pixel-setup.md +90 -0
- package/workflows/_uncategorized/ponytail-review.md +59 -0
- package/workflows/_uncategorized/reverse-android-build.md +222 -0
- package/workflows/_uncategorized/reverse-android-design.md +139 -0
- package/workflows/_uncategorized/reverse-android-discover.md +150 -0
- package/workflows/_uncategorized/reverse-android-scan.md +158 -0
- package/workflows/_uncategorized/reverse-android.md +143 -0
- package/workflows/_uncategorized/reverse-ios-build.md +240 -0
- package/workflows/_uncategorized/reverse-ios-design.md +112 -0
- package/workflows/_uncategorized/reverse-ios-discover.md +120 -0
- package/workflows/_uncategorized/reverse-ios-scan.md +155 -0
- package/workflows/_uncategorized/reverse-ios.md +152 -0
- package/workflows/_uncategorized/safety-router.md +34 -0
- package/workflows/_uncategorized/teach.md +89 -0
- package/workflows/_uncategorized/verify-ui.md +53 -0
- package/workflows/_uncategorized/visualize-screenshots.md +34 -0
- package/workflows/ads/ads-analyst.md +201 -0
- package/workflows/ads/ads-audit.md +106 -0
- package/workflows/ads/ads-optimize.md +97 -0
- package/workflows/ads/ads-targeting.md +241 -0
- package/workflows/ads/adsExpert.md +160 -0
- package/workflows/ads/smali-ads-config.md +400 -0
- package/workflows/ads/smali-ads-flow.md +331 -0
- package/workflows/ads/smali-ads-interstitial.md +377 -0
- package/workflows/ads/smali-ads-native.md +382 -0
- package/workflows/context/teach.md +89 -0
- package/workflows/gitnexus.md +8 -8
- package/workflows/lifecycle/brainstorm.md +43 -0
- package/workflows/lifecycle/code.md +5 -0
- package/workflows/lifecycle/init.md +23 -5
- package/workflows/lifecycle/multi-model-pipeline.md +60 -0
- package/workflows/quality/ponytail-review.md +59 -0
- package/workflows/roles/critic.md +40 -0
- 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()
|