@mmerterden/multi-agent-pipeline 14.2.1 → 14.2.2
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/CHANGELOG.md +42 -0
- package/README.md +10 -6
- package/README.tr.md +143 -0
- package/docs/architecture.md +23 -8
- package/docs/ecosystem.md +237 -0
- package/install/_plugin-skills.mjs +16 -2
- package/install/codex.mjs +9 -4
- package/install/templates/copilot-instructions.md +12 -9
- package/package.json +1 -1
- package/pipeline/commands/multi-agent/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/analysis/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/autopilot/SKILL.md +6 -2
- package/pipeline/commands/multi-agent/channels/SKILL.md +15 -4
- package/pipeline/commands/multi-agent/create-jira/SKILL.md +4 -4
- package/pipeline/commands/multi-agent/dev/SKILL.md +10 -23
- package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +10 -2
- package/pipeline/commands/multi-agent/dev-local/SKILL.md +10 -24
- package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +10 -3
- package/pipeline/commands/multi-agent/jira/SKILL.md +13 -2
- package/pipeline/commands/multi-agent/language/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/local/SKILL.md +6 -2
- package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +6 -2
- package/pipeline/commands/multi-agent/log/SKILL.md +7 -1
- package/pipeline/commands/multi-agent/ship/SKILL.md +5 -1
- package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
- package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
- package/pipeline/lib/extract-conventions.sh +44 -15
- package/pipeline/lib/fetch-figma-annotations.sh +8 -1
- package/pipeline/lib/fetch-fortify.sh +23 -8
- package/pipeline/lib/figma-screenshot.sh +11 -1
- package/pipeline/lib/issue-fetcher.sh +76 -9
- package/pipeline/lib/md2confluence-v3.py +16 -2
- package/pipeline/lib/plan-todos.sh +5 -2
- package/pipeline/lib/post-pr-review.sh +8 -6
- package/pipeline/lib/shadow-git.sh +50 -9
- package/pipeline/lib/submodule-detector.sh +8 -1
- package/pipeline/multi-agent-refs/_input-parser.md +1 -1
- package/pipeline/multi-agent-refs/channels/confluence.md +3 -0
- package/pipeline/multi-agent-refs/channels/issue-comment.md +2 -2
- package/pipeline/multi-agent-refs/channels/jira.md +13 -2
- package/pipeline/multi-agent-refs/channels/pr-review-actions.md +1 -1
- package/pipeline/multi-agent-refs/channels/pr.md +20 -0
- package/pipeline/multi-agent-refs/channels/wiki.md +1 -1
- package/pipeline/multi-agent-refs/cross-cli-contract.md +3 -3
- package/pipeline/multi-agent-refs/features/worktree-finalize.md +1 -1
- package/pipeline/multi-agent-refs/generate-issue.md +2 -2
- package/pipeline/multi-agent-refs/issue-jira-triad.md +3 -3
- package/pipeline/multi-agent-refs/payload-contracts.md +67 -0
- package/pipeline/multi-agent-refs/phases/modes.md +20 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-6-commit.md +8 -40
- package/pipeline/multi-agent-refs/phases/phase-7-report.md +5 -3
- package/pipeline/multi-agent-refs/phases.md +6 -0
- package/pipeline/multi-agent-refs/rules.md +2 -0
- package/pipeline/schemas/prefs.schema.json +1 -1
- package/pipeline/scripts/audit-log-rotate.sh +10 -0
- package/pipeline/scripts/build-stack-plugins.mjs +8 -1
- package/pipeline/scripts/check-derived-drift.mjs +13 -1
- package/pipeline/scripts/diff-explain.mjs +41 -3
- package/pipeline/scripts/diff-risk-score.mjs +72 -8
- package/pipeline/scripts/gen-mode-dispatch.mjs +1 -1
- package/pipeline/scripts/learning-curve.mjs +8 -2
- package/pipeline/scripts/output-quality-check.sh +15 -4
- package/pipeline/scripts/phase-tracker.sh +21 -8
- package/pipeline/scripts/pre-commit-check.sh +69 -22
- package/pipeline/scripts/render-agent-log-cost.sh +8 -3
- package/pipeline/scripts/render-cost-summary.sh +42 -22
- package/pipeline/scripts/render-work-summary.sh +47 -13
- package/pipeline/scripts/review-scope.mjs +1 -1
- package/pipeline/scripts/run-aggregator.mjs +38 -14
- package/pipeline/scripts/smoke-schema-validation.sh +5 -1
- package/pipeline/scripts/test-gap-scan.mjs +45 -6
- package/pipeline/scripts/uninstall.mjs +39 -4
- package/pipeline/scripts/update-issue-progress.sh +12 -16
- package/pipeline/scripts/worktree-finalize.sh +23 -2
- package/pipeline/skills/.skills-index.json +11 -2
- package/pipeline/skills/shared/README.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-autopilot/SKILL.md +4 -0
- package/pipeline/skills/shared/core/multi-agent-create-jira/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-dev/SKILL.md +4 -17
- package/pipeline/skills/shared/core/multi-agent-dev-autopilot/SKILL.md +8 -0
- package/pipeline/skills/shared/core/multi-agent-dev-local/SKILL.md +5 -18
- package/pipeline/skills/shared/core/multi-agent-dev-local-autopilot/SKILL.md +8 -0
- package/pipeline/skills/shared/core/multi-agent-language/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-local/SKILL.md +4 -0
- package/pipeline/skills/shared/core/multi-agent-local-autopilot/SKILL.md +4 -0
- package/pipeline/skills/shared/core/multi-agent-ship/SKILL.md +4 -0
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +2 -2
- package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +302 -0
- package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +144 -0
- package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +156 -0
- package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +108 -0
- package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +175 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +865 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +335 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +344 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +130 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +264 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +298 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +529 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +187 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +171 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +184 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/snapshot-resources.sh +26 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +173 -0
- package/pipeline/skills/skills-index.md +3 -2
package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Resolve LEGACY translation values for a list of keys.
|
|
3
|
+
|
|
4
|
+
PRIMARY source - the in-repo legacy snapshot (`--snapshot-root`): one flat
|
|
5
|
+
`<lang>.json` of `{key: value}` per language, refreshed offline by
|
|
6
|
+
`fetch-legacy-labels.py`. No network per run.
|
|
7
|
+
|
|
8
|
+
`--live` re-reads the project's label endpoint directly, to refresh or to verify the
|
|
9
|
+
snapshot. That endpoint is project-specific, so nothing about it is baked in - supply
|
|
10
|
+
it the same way `fetch-legacy-labels.py` takes it:
|
|
11
|
+
|
|
12
|
+
resolve-legacy-values.py --keys Continue,Cancel --langs all --live \\
|
|
13
|
+
--endpoint 'https://labels.example.internal/{env}/labels/{lang}' \\
|
|
14
|
+
--headers-file .secrets/label-headers.json
|
|
15
|
+
|
|
16
|
+
Prefer --headers-file (JSON object) over repeated --header for anything secret.
|
|
17
|
+
|
|
18
|
+
`--prefix` covers backends that store keys under a namespace prefix the app strips
|
|
19
|
+
(e.g. a stored `Mobile-Continue` for the app's `Continue`); pass keys WITHOUT it.
|
|
20
|
+
|
|
21
|
+
LAST-RESORT offline fallback (`--plist-root`): a legacy iOS app's shipped
|
|
22
|
+
`<lang>.lproj/language.plist`. It is typically a stale subset - the snapshot and the
|
|
23
|
+
service are authoritative; a plist hit deserves a note in the map.
|
|
24
|
+
|
|
25
|
+
Output: `{key: {lang: value|null}}`. Never invent a value - `null` is a valid answer.
|
|
26
|
+
"""
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
import urllib.request
|
|
32
|
+
|
|
33
|
+
LANGS_ALL = ["en", "tr", "ar", "de", "es", "fr", "it", "ru"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _largest_str_map(obj):
|
|
37
|
+
best, stack = {}, [obj]
|
|
38
|
+
while stack:
|
|
39
|
+
cur = stack.pop()
|
|
40
|
+
if isinstance(cur, dict):
|
|
41
|
+
strs = {k: v for k, v in cur.items() if isinstance(v, str)}
|
|
42
|
+
if len(strs) > len(best):
|
|
43
|
+
best = strs
|
|
44
|
+
stack.extend(cur.values())
|
|
45
|
+
elif isinstance(cur, list):
|
|
46
|
+
stack.extend(cur)
|
|
47
|
+
return best
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _parse_headers(a):
|
|
51
|
+
headers = {"Accept": "application/json"}
|
|
52
|
+
if a.headers_file:
|
|
53
|
+
with open(a.headers_file, encoding="utf-8") as fh:
|
|
54
|
+
loaded = json.load(fh)
|
|
55
|
+
if not isinstance(loaded, dict):
|
|
56
|
+
raise SystemExit(f"--headers-file must be a JSON object, got {type(loaded).__name__}")
|
|
57
|
+
headers.update({str(k): str(v) for k, v in loaded.items()})
|
|
58
|
+
for pair in a.header or []:
|
|
59
|
+
if "=" not in pair:
|
|
60
|
+
raise SystemExit(f"--header expects K=V, got {pair!r}")
|
|
61
|
+
k, v = pair.split("=", 1)
|
|
62
|
+
headers[k.strip()] = v.strip()
|
|
63
|
+
return headers
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse_fail_statuses(values):
|
|
67
|
+
out = {}
|
|
68
|
+
for pair in values or []:
|
|
69
|
+
if "=" not in pair:
|
|
70
|
+
raise SystemExit(f"--fail-status expects CODE=message, got {pair!r}")
|
|
71
|
+
code, message = pair.split("=", 1)
|
|
72
|
+
out[code.strip()] = message.strip()
|
|
73
|
+
return out
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def fetch(lang, a):
|
|
77
|
+
if not a.endpoint:
|
|
78
|
+
raise RuntimeError("--live needs --endpoint (no label endpoint is built in)")
|
|
79
|
+
url = a.endpoint.format(env=a.env, lang=lang)
|
|
80
|
+
headers = _parse_headers(a)
|
|
81
|
+
headers.setdefault("Accept-Language", lang)
|
|
82
|
+
req = urllib.request.Request(url, headers=headers)
|
|
83
|
+
with urllib.request.urlopen(req, timeout=a.timeout) as r:
|
|
84
|
+
data = json.loads(r.read().decode("utf-8"))
|
|
85
|
+
fail_statuses = _parse_fail_statuses(a.fail_status)
|
|
86
|
+
if isinstance(data, dict) and a.status_field in data:
|
|
87
|
+
code = str(data.get(a.status_field))
|
|
88
|
+
if code in fail_statuses:
|
|
89
|
+
raise RuntimeError(f"{url}: {fail_statuses[code]} (status {code})")
|
|
90
|
+
m = _largest_str_map(data)
|
|
91
|
+
if not m:
|
|
92
|
+
raise RuntimeError(f"no label map found in the response for '{lang}' ({url})")
|
|
93
|
+
return m
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def resolve_api(keys, langs, a):
|
|
97
|
+
out = {k: {} for k in keys}
|
|
98
|
+
for lang in langs:
|
|
99
|
+
m = fetch(lang, a)
|
|
100
|
+
for k in keys:
|
|
101
|
+
out[k][lang] = m.get(a.prefix + k, m.get(k))
|
|
102
|
+
return out
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def resolve_snapshot(keys, langs, root, prefix):
|
|
106
|
+
"""Read the in-repo legacy snapshot (<root>/<lang>.json, flat {key: value}) -
|
|
107
|
+
offline, no network. Refresh it with fetch-legacy-labels.py."""
|
|
108
|
+
maps = {}
|
|
109
|
+
for l in langs:
|
|
110
|
+
p = os.path.join(root, f"{l}.json")
|
|
111
|
+
if os.path.isfile(p):
|
|
112
|
+
with open(p, encoding="utf-8") as fh:
|
|
113
|
+
maps[l] = json.load(fh)
|
|
114
|
+
else:
|
|
115
|
+
sys.stderr.write(f"# no legacy snapshot for '{l}' under {root}\n")
|
|
116
|
+
return {k: {l: maps.get(l, {}).get(prefix + k, maps.get(l, {}).get(k)) for l in langs} for k in keys}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def resolve_plist(keys, langs, root, prefix):
|
|
120
|
+
import plistlib
|
|
121
|
+
pl = {}
|
|
122
|
+
for l in langs:
|
|
123
|
+
for cand in (os.path.join(root, f"{l}.lproj", "language.plist"), os.path.join(root, f"{l}.plist")):
|
|
124
|
+
if os.path.isfile(cand):
|
|
125
|
+
with open(cand, "rb") as fh:
|
|
126
|
+
pl[l] = plistlib.load(fh)
|
|
127
|
+
break
|
|
128
|
+
else:
|
|
129
|
+
sys.stderr.write(f"# no plist for '{l}' under {root}\n")
|
|
130
|
+
return {k: {l: pl.get(l, {}).get(prefix + k, pl.get(l, {}).get(k)) for l in langs} for k in keys}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def main():
|
|
134
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
135
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
136
|
+
ap.add_argument("--keys", required=True,
|
|
137
|
+
help="comma-separated legacy keys, WITHOUT the --prefix")
|
|
138
|
+
ap.add_argument("--langs", default="en,tr", help="comma-separated langs, or 'all'")
|
|
139
|
+
ap.add_argument("--snapshot-root", help="PRIMARY: in-repo legacy snapshot dir - offline, no network")
|
|
140
|
+
ap.add_argument("--live", action="store_true",
|
|
141
|
+
help="bypass the snapshot and read --endpoint live (to refresh / verify)")
|
|
142
|
+
ap.add_argument("--endpoint", help="URL template for the legacy label map; may contain {lang} and {env}")
|
|
143
|
+
ap.add_argument("--header", action="append",
|
|
144
|
+
help="extra request header as K=V (repeatable); avoid for secrets")
|
|
145
|
+
ap.add_argument("--headers-file", help="JSON object of request headers - use this for tokens")
|
|
146
|
+
ap.add_argument("--env", default="", help="value substituted for {env} in --endpoint")
|
|
147
|
+
ap.add_argument("--status-field", default="statusCode",
|
|
148
|
+
help="response field carrying a provider status code")
|
|
149
|
+
ap.add_argument("--fail-status", action="append",
|
|
150
|
+
help="CODE=message for a provider error code that must abort (repeatable)")
|
|
151
|
+
ap.add_argument("--timeout", type=int, default=30)
|
|
152
|
+
ap.add_argument("--plist-root", help="last-resort offline fallback: dir with <lang>.lproj/language.plist")
|
|
153
|
+
ap.add_argument("--prefix", default="", help="prefix the backend stores keys under (default none)")
|
|
154
|
+
a = ap.parse_args()
|
|
155
|
+
|
|
156
|
+
langs = LANGS_ALL if a.langs.strip() == "all" else [l.strip() for l in a.langs.split(",") if l.strip()]
|
|
157
|
+
keys = [k.strip() for k in a.keys.split(",") if k.strip()]
|
|
158
|
+
|
|
159
|
+
# Preference: in-repo snapshot (offline) → live endpoint → plist.
|
|
160
|
+
if a.snapshot_root and not a.live:
|
|
161
|
+
out = resolve_snapshot(keys, langs, a.snapshot_root, a.prefix)
|
|
162
|
+
elif a.plist_root and not a.live and not a.snapshot_root:
|
|
163
|
+
sys.stderr.write("# OFFLINE plist fallback - the snapshot/service are the source of truth.\n")
|
|
164
|
+
out = resolve_plist(keys, langs, a.plist_root, a.prefix)
|
|
165
|
+
else:
|
|
166
|
+
try:
|
|
167
|
+
out = resolve_api(keys, langs, a)
|
|
168
|
+
except Exception as exc:
|
|
169
|
+
sys.stderr.write(f"# live label fetch failed ({exc}).\n")
|
|
170
|
+
if a.snapshot_root:
|
|
171
|
+
sys.stderr.write("# falling back to --snapshot-root.\n")
|
|
172
|
+
out = resolve_snapshot(keys, langs, a.snapshot_root, a.prefix)
|
|
173
|
+
elif a.plist_root:
|
|
174
|
+
sys.stderr.write("# falling back to --plist-root.\n")
|
|
175
|
+
out = resolve_plist(keys, langs, a.plist_root, a.prefix)
|
|
176
|
+
else:
|
|
177
|
+
raise
|
|
178
|
+
|
|
179
|
+
json.dump(out, sys.stdout, ensure_ascii=False, indent=2)
|
|
180
|
+
sys.stdout.write("\n")
|
|
181
|
+
absent = [k for k, v in out.items() if not any(v.values())]
|
|
182
|
+
if absent:
|
|
183
|
+
sys.stderr.write(f"\n# {len(absent)} key(s) with no value: " + ", ".join(absent) + "\n")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
if __name__ == "__main__":
|
|
187
|
+
main()
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Bulk-resolve the redesign (new) translation values for a list of localization keys.
|
|
3
|
+
|
|
4
|
+
Reads the per-key authored source under
|
|
5
|
+
`<resources>/Resources/Localization/LocalizationStrings/Sources/Suggested/<Key>.json`
|
|
6
|
+
(SSOT in the resources repo) and prints `{key: {lang: value|null}}`.
|
|
7
|
+
|
|
8
|
+
Why a script: the model otherwise hand-copies 8 languages x N keys out of JSON
|
|
9
|
+
files - slow, token-heavy, and error-prone. This is pure file IO, so it is a
|
|
10
|
+
script. Read-only; touches nothing.
|
|
11
|
+
|
|
12
|
+
Key-form handling: code often references a key as `AddPassenger.continueButton`
|
|
13
|
+
(camelCase leaf) while the Suggested filename PascalCases every dotted segment
|
|
14
|
+
(`AddPassenger.ContinueButton.json`). We normalise the first character of
|
|
15
|
+
each segment to upper-case before resolving, so either form works as input.
|
|
16
|
+
Accessibility keys live under `AccessibilityStrings/Sources/Suggested/` - pass
|
|
17
|
+
`--domain accessibility` for those.
|
|
18
|
+
|
|
19
|
+
`--catalog <file>`: the SHIPPED string catalog, checked whenever a key has no Suggested
|
|
20
|
+
file. Pass it. A missing Suggested file means "not in this snapshot", NOT "this key is
|
|
21
|
+
unauthored" - the snapshot is a generated mirror and goes stale the moment a key is added
|
|
22
|
+
upstream. Concluding "unauthored" from the snapshot alone has already produced a batch of
|
|
23
|
+
pointless authoring requests for keys that were shipping in all eight languages. With
|
|
24
|
+
`--catalog`, each key reports where its values came from (`suggested` / `catalog`), and
|
|
25
|
+
only keys absent from BOTH are reported as genuinely unauthored.
|
|
26
|
+
|
|
27
|
+
Accepts an Apple `.xcstrings` (`{"strings": {key: {"localizations": {lang: {"stringUnit":
|
|
28
|
+
{"value": ...}}}}}}`) or a flat `{key: {lang: value}}` map.
|
|
29
|
+
"""
|
|
30
|
+
import argparse
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import sys
|
|
34
|
+
|
|
35
|
+
DOMAIN_DIR = {
|
|
36
|
+
"localization": "LocalizationStrings",
|
|
37
|
+
"accessibility": "AccessibilityStrings",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def normalise(key: str) -> str:
|
|
42
|
+
"""PascalCase the first char of every dotted segment (file naming convention)."""
|
|
43
|
+
return ".".join(s[:1].upper() + s[1:] if s else s for s in key.split("."))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
SNAPSHOT_SUBDIR = {"localization": "Suggested", "accessibility": "AccessibilitySuggested"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def suggested_dir(resources_root: str, domain: str) -> str:
|
|
50
|
+
# In-repo snapshot layout first (specs/resources/Localization/Suggested) - single-repo, offline;
|
|
51
|
+
# else the full the resources repo layout.
|
|
52
|
+
snap = os.path.join(resources_root, "Localization", SNAPSHOT_SUBDIR[domain])
|
|
53
|
+
if os.path.isdir(snap):
|
|
54
|
+
return snap
|
|
55
|
+
return os.path.join(
|
|
56
|
+
resources_root, "Resources", "Localization",
|
|
57
|
+
DOMAIN_DIR[domain], "Sources", "Suggested",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve(key: str, sug_dir: str, langs):
|
|
62
|
+
path = os.path.join(sug_dir, normalise(key) + ".json")
|
|
63
|
+
if not os.path.isfile(path):
|
|
64
|
+
return None # absent from THIS snapshot - see load_catalog before concluding
|
|
65
|
+
with open(path, encoding="utf-8") as fh:
|
|
66
|
+
data = json.load(fh)
|
|
67
|
+
out = {}
|
|
68
|
+
for lang in langs:
|
|
69
|
+
entry = data.get(lang)
|
|
70
|
+
out[lang] = entry.get("value") if isinstance(entry, dict) else None
|
|
71
|
+
return out
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_catalog(path):
|
|
75
|
+
"""{key: {lang: value}} from an .xcstrings or an already-flat catalog."""
|
|
76
|
+
if not path:
|
|
77
|
+
return {}
|
|
78
|
+
with open(path, encoding="utf-8") as fh:
|
|
79
|
+
raw = json.load(fh)
|
|
80
|
+
strings = raw.get("strings") if isinstance(raw, dict) else None
|
|
81
|
+
if not isinstance(strings, dict):
|
|
82
|
+
return raw if isinstance(raw, dict) else {}
|
|
83
|
+
out = {}
|
|
84
|
+
for key, entry in strings.items():
|
|
85
|
+
locs = (entry or {}).get("localizations") or {}
|
|
86
|
+
out[key] = {lang: ((v or {}).get("stringUnit") or {}).get("value")
|
|
87
|
+
for lang, v in locs.items()}
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def resolve_from_catalog(key, catalog, langs):
|
|
92
|
+
for cand in (key, normalise(key)):
|
|
93
|
+
vals = catalog.get(cand)
|
|
94
|
+
if vals:
|
|
95
|
+
return {lang: vals.get(lang) for lang in langs}
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main():
|
|
100
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
101
|
+
ap.add_argument("--keys", help="comma-separated keys (code- or file-form)")
|
|
102
|
+
ap.add_argument("--keys-file", help="file with one key per line (- for stdin)")
|
|
103
|
+
ap.add_argument("--resources-root",
|
|
104
|
+
help="resources checkout OR the in-repo snapshot root (specs/resources)")
|
|
105
|
+
ap.add_argument("--suggested-dir", help="explicit Suggested dir (overrides --resources-root detection)")
|
|
106
|
+
ap.add_argument("--domain", choices=list(DOMAIN_DIR), default="localization")
|
|
107
|
+
ap.add_argument("--catalog", help="shipped string catalog (.xcstrings or flat JSON) used "
|
|
108
|
+
"whenever a key has no Suggested file - pass it, or a "
|
|
109
|
+
"stale snapshot reads as 'unauthored'")
|
|
110
|
+
ap.add_argument("--report-source", action="store_true",
|
|
111
|
+
help="emit {key: {source, values}} instead of {key: values}")
|
|
112
|
+
ap.add_argument("--langs", default="en,tr",
|
|
113
|
+
help="comma-separated langs, or 'all' (ar,de,en,es,fr,it,ru,tr)")
|
|
114
|
+
args = ap.parse_args()
|
|
115
|
+
|
|
116
|
+
if args.langs.strip() == "all":
|
|
117
|
+
langs = ["ar", "de", "en", "es", "fr", "it", "ru", "tr"]
|
|
118
|
+
else:
|
|
119
|
+
langs = [l.strip() for l in args.langs.split(",") if l.strip()]
|
|
120
|
+
|
|
121
|
+
keys = []
|
|
122
|
+
if args.keys:
|
|
123
|
+
keys += [k.strip() for k in args.keys.split(",") if k.strip()]
|
|
124
|
+
if args.keys_file:
|
|
125
|
+
src = sys.stdin if args.keys_file == "-" else open(args.keys_file, encoding="utf-8")
|
|
126
|
+
keys += [ln.strip() for ln in src if ln.strip()]
|
|
127
|
+
if not keys:
|
|
128
|
+
ap.error("supply --keys and/or --keys-file")
|
|
129
|
+
|
|
130
|
+
if args.suggested_dir:
|
|
131
|
+
sug_dir = args.suggested_dir
|
|
132
|
+
elif args.resources_root:
|
|
133
|
+
sug_dir = suggested_dir(args.resources_root, args.domain)
|
|
134
|
+
else:
|
|
135
|
+
ap.error("supply --resources-root or --suggested-dir")
|
|
136
|
+
if not os.path.isdir(sug_dir):
|
|
137
|
+
ap.error(f"Suggested dir not found: {sug_dir} (is --resources-root / --suggested-dir correct?)")
|
|
138
|
+
|
|
139
|
+
catalog = load_catalog(args.catalog)
|
|
140
|
+
result, source = {}, {}
|
|
141
|
+
for k in keys:
|
|
142
|
+
vals = resolve(k, sug_dir, langs)
|
|
143
|
+
src = "suggested" if vals is not None else None
|
|
144
|
+
if vals is None and catalog:
|
|
145
|
+
vals = resolve_from_catalog(k, catalog, langs)
|
|
146
|
+
src = "catalog" if vals is not None else None
|
|
147
|
+
result[k] = vals
|
|
148
|
+
source[k] = src
|
|
149
|
+
|
|
150
|
+
if args.report_source:
|
|
151
|
+
json.dump({k: {"source": source[k], "values": result[k]} for k in keys},
|
|
152
|
+
sys.stdout, ensure_ascii=False, indent=2)
|
|
153
|
+
else:
|
|
154
|
+
json.dump(result, sys.stdout, ensure_ascii=False, indent=2)
|
|
155
|
+
sys.stdout.write("\n")
|
|
156
|
+
|
|
157
|
+
from_catalog = [k for k in keys if source[k] == "catalog"]
|
|
158
|
+
unauthored = [k for k in keys if source[k] is None]
|
|
159
|
+
if from_catalog:
|
|
160
|
+
sys.stderr.write(
|
|
161
|
+
f"\n# {len(from_catalog)} key(s) resolved from the CATALOG, not the snapshot - "
|
|
162
|
+
"the snapshot is stale, refresh it with snapshot-resources.sh: "
|
|
163
|
+
+ ", ".join(from_catalog) + "\n")
|
|
164
|
+
if unauthored:
|
|
165
|
+
label = ("with no authored source" if catalog else
|
|
166
|
+
"absent from the snapshot (no --catalog given, so 'unauthored' is UNVERIFIED)")
|
|
167
|
+
sys.stderr.write(f"\n# {len(unauthored)} key(s) {label}: " + ", ".join(unauthored) + "\n")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
if __name__ == "__main__":
|
|
171
|
+
main()
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Scan an *implemented* screen for the localization keys the registry×code union misses.
|
|
3
|
+
|
|
4
|
+
The mapper's primary new-key recipe is a STATIC cross-reference (design-export
|
|
5
|
+
components × component-registry `localizationKeys` × the screen's wired call sites).
|
|
6
|
+
That recipe is structurally blind to three things, which this scanner recovers by
|
|
7
|
+
reading the screen's actual source tree:
|
|
8
|
+
|
|
9
|
+
1. **Error / validation / alert strings** - copy that lives in validators, error
|
|
10
|
+
mappers, alert/toast builders, or ViewModel error branches, not in the
|
|
11
|
+
VC→VM→cell chain the static recipe walks.
|
|
12
|
+
2. **Dynamic / interpolated keys** - keys built at runtime: `String(format:)`,
|
|
13
|
+
interpolated/variable `.localized` receivers, `LocalizationStringKey(<expr>)`,
|
|
14
|
+
Android `getString(<var>)` - none of which are string literals, so the literal
|
|
15
|
+
greps never see them.
|
|
16
|
+
3. **Keys wired in code but absent from the component registry** (and the inverse
|
|
17
|
+
is the agent's job to confirm) - surfaced here as plain `static` call sites so
|
|
18
|
+
the agent can diff them against the registry union.
|
|
19
|
+
|
|
20
|
+
Heuristic + read-only: it greps `.swift`/`.kt`/`.xml`/`.ts`/`.tsx`/`.js`/`.jsx`/`.vue`
|
|
21
|
+
under --screen-path and classifies each hit `static | error | dynamic` with
|
|
22
|
+
file:line provenance. It does
|
|
23
|
+
NOT resolve values or invent keys - it hands the agent a categorized worklist to
|
|
24
|
+
fold into the mapping (errors and dynamics especially), where each still gets the
|
|
25
|
+
normal new/legacy/CMS resolution.
|
|
26
|
+
|
|
27
|
+
Output (to --out or stdout): { screenPath, counts:{static,error,dynamic},
|
|
28
|
+
keys:[ {key, category, platform, file, line, snippet} ] }.
|
|
29
|
+
"""
|
|
30
|
+
import argparse
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import sys
|
|
35
|
+
|
|
36
|
+
EXTS = (".swift", ".kt", ".kts", ".xml", ".ts", ".tsx", ".js", ".jsx", ".vue")
|
|
37
|
+
ERROR_CTX = re.compile(
|
|
38
|
+
r"alert|error|validat|warning|toast|snackbar|failure|exception|errorMessage|showError",
|
|
39
|
+
re.I)
|
|
40
|
+
|
|
41
|
+
# iOS
|
|
42
|
+
RE_LSK = re.compile(r"LocalizationStringKey\.([A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)+)")
|
|
43
|
+
RE_LSK_DYN = re.compile(r"LocalizationStringKey\s*\(")
|
|
44
|
+
RE_LITERAL_LOCALIZED = re.compile(r'"([^"\\]+)"\s*\.\s*localized')
|
|
45
|
+
RE_VAR_LOCALIZED = re.compile(r'(?<!")\b([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)*)\s*\.\s*localized')
|
|
46
|
+
RE_STRING_FORMAT = re.compile(r"String\s*\(\s*format\s*:")
|
|
47
|
+
# Android
|
|
48
|
+
RE_R_STRING = re.compile(r"R\.string\.([A-Za-z0-9_]+)")
|
|
49
|
+
RE_AT_STRING = re.compile(r"@string/([A-Za-z0-9_]+)")
|
|
50
|
+
RE_GETSTRING_DYN = re.compile(r"getString\s*\(\s*[A-Za-z_]") # variable, not R.string.literal
|
|
51
|
+
# Web (react-i18next / next-intl `t('key')`; vue-i18n `$t('key')`); `(?<!\$)\bt\(`
|
|
52
|
+
# keeps a plain t(...) call from also matching inside a $t(...) call.
|
|
53
|
+
RE_WEB_T = re.compile(r"(?<!\$)\bt\(\s*['\"]([A-Za-z0-9_.\-]+)['\"]")
|
|
54
|
+
RE_WEB_VUE_T = re.compile(r"\$t\(\s*['\"]([A-Za-z0-9_.\-]+)['\"]")
|
|
55
|
+
RE_WEB_T_DYN = re.compile(r"(?<!\$)\bt\(\s*[`$A-Za-z_]") # template literal or variable, not a literal
|
|
56
|
+
RE_WEB_VUE_T_DYN = re.compile(r"\$t\(\s*[`$A-Za-z_]")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def platform_of(path):
|
|
60
|
+
if path.endswith((".kt", ".kts")):
|
|
61
|
+
return "android"
|
|
62
|
+
if path.endswith(".xml"):
|
|
63
|
+
return "android"
|
|
64
|
+
if path.endswith((".ts", ".tsx", ".js", ".jsx", ".vue")):
|
|
65
|
+
return "web"
|
|
66
|
+
return "ios"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def classify(line):
|
|
70
|
+
return "error" if ERROR_CTX.search(line) else None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def scan_line(path, lineno, line, out, seen):
|
|
74
|
+
plat = platform_of(path)
|
|
75
|
+
err = classify(line)
|
|
76
|
+
|
|
77
|
+
def add(key, category, dyn=False):
|
|
78
|
+
cat = "dynamic" if dyn else (category or "static")
|
|
79
|
+
# error context upgrades a static/dynamic hit's category to error only when
|
|
80
|
+
# it isn't already dynamic (dynamic is the more actionable label)
|
|
81
|
+
if err and cat == "static":
|
|
82
|
+
cat = "error"
|
|
83
|
+
sig = (key, cat, path, lineno)
|
|
84
|
+
if sig in seen:
|
|
85
|
+
return
|
|
86
|
+
seen.add(sig)
|
|
87
|
+
out.append({
|
|
88
|
+
"key": key, "category": cat, "platform": plat,
|
|
89
|
+
"file": path, "line": lineno, "snippet": line.strip()[:200],
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
if path.endswith(".xml"):
|
|
93
|
+
for m in RE_AT_STRING.finditer(line):
|
|
94
|
+
add(m.group(1), err)
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
if plat == "web":
|
|
98
|
+
for m in RE_WEB_T.finditer(line):
|
|
99
|
+
add(m.group(1), err)
|
|
100
|
+
for m in RE_WEB_VUE_T.finditer(line):
|
|
101
|
+
add(m.group(1), err)
|
|
102
|
+
if RE_WEB_T_DYN.search(line):
|
|
103
|
+
add("(dynamic: t(...))", err, dyn=True)
|
|
104
|
+
if RE_WEB_VUE_T_DYN.search(line):
|
|
105
|
+
add("(dynamic: $t(...))", err, dyn=True)
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
# iOS / Kotlin source
|
|
109
|
+
for m in RE_LSK.finditer(line):
|
|
110
|
+
add(m.group(1), err)
|
|
111
|
+
for m in RE_LITERAL_LOCALIZED.finditer(line):
|
|
112
|
+
add(m.group(1), err)
|
|
113
|
+
for m in RE_R_STRING.finditer(line):
|
|
114
|
+
add(m.group(1), err)
|
|
115
|
+
|
|
116
|
+
# dynamic constructions - record the expression, not a resolvable key
|
|
117
|
+
if RE_LSK_DYN.search(line):
|
|
118
|
+
add("(dynamic: LocalizationStringKey(...))", err, dyn=True)
|
|
119
|
+
if RE_STRING_FORMAT.search(line) and (".localized" in line or "LocalizationStringKey" in line):
|
|
120
|
+
add("(dynamic: String(format:...))", err, dyn=True)
|
|
121
|
+
if RE_GETSTRING_DYN.search(line) and "R.string." not in line:
|
|
122
|
+
add("(dynamic: getString(var))", err, dyn=True)
|
|
123
|
+
# variable .localized receiver (not a string literal, not the iOS key enum)
|
|
124
|
+
for m in RE_VAR_LOCALIZED.finditer(line):
|
|
125
|
+
recv = m.group(1)
|
|
126
|
+
if recv.startswith("LocalizationStringKey"):
|
|
127
|
+
continue
|
|
128
|
+
add(f"(dynamic: {recv}.localized)", err, dyn=True)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def main():
|
|
132
|
+
ap = argparse.ArgumentParser(description=__doc__,
|
|
133
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
134
|
+
ap.add_argument("--screen-path", required=True,
|
|
135
|
+
help="dir (or file) of the implemented screen to scan")
|
|
136
|
+
ap.add_argument("--out", help="write JSON here (default: stdout)")
|
|
137
|
+
ap.add_argument("--category", choices=["all", "error", "dynamic", "static"], default="all",
|
|
138
|
+
help="filter the emitted keys (default all)")
|
|
139
|
+
args = ap.parse_args()
|
|
140
|
+
|
|
141
|
+
root = args.screen_path
|
|
142
|
+
if not os.path.exists(root):
|
|
143
|
+
ap.error(f"--screen-path not found: {root}")
|
|
144
|
+
|
|
145
|
+
files = []
|
|
146
|
+
if os.path.isfile(root):
|
|
147
|
+
files = [root]
|
|
148
|
+
else:
|
|
149
|
+
for dirpath, _dirs, names in os.walk(root):
|
|
150
|
+
if "/.git" in dirpath or "/Generated/" in dirpath:
|
|
151
|
+
continue
|
|
152
|
+
for n in names:
|
|
153
|
+
if n.endswith(EXTS):
|
|
154
|
+
files.append(os.path.join(dirpath, n))
|
|
155
|
+
|
|
156
|
+
out, seen = [], set()
|
|
157
|
+
for path in sorted(files):
|
|
158
|
+
try:
|
|
159
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
160
|
+
for i, line in enumerate(fh, 1):
|
|
161
|
+
scan_line(path, i, line, out, seen)
|
|
162
|
+
except OSError:
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
if args.category != "all":
|
|
166
|
+
out = [k for k in out if k["category"] == args.category]
|
|
167
|
+
out.sort(key=lambda k: (k["category"], k["file"], k["line"]))
|
|
168
|
+
|
|
169
|
+
counts = {c: sum(1 for k in out if k["category"] == c) for c in ("static", "error", "dynamic")}
|
|
170
|
+
result = {"screenPath": root, "filesScanned": len(files), "counts": counts, "keys": out}
|
|
171
|
+
|
|
172
|
+
text = json.dumps(result, ensure_ascii=False, indent=2)
|
|
173
|
+
if args.out:
|
|
174
|
+
with open(args.out, "w", encoding="utf-8") as fh:
|
|
175
|
+
fh.write(text + "\n")
|
|
176
|
+
else:
|
|
177
|
+
sys.stdout.write(text + "\n")
|
|
178
|
+
sys.stderr.write(f"# scanned {len(files)} file(s): "
|
|
179
|
+
f"{counts['static']} static · {counts['error']} error · "
|
|
180
|
+
f"{counts['dynamic']} dynamic\n")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
main()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Snapshot the redesign resources (component registry + Suggested translations) from
|
|
3
|
+
# the resources repo into specs/resources, so the localization
|
|
4
|
+
# mapper reads everything from one repo (like design-export). Generated, not authored -
|
|
5
|
+
# refresh from resources; never hand-edit the snapshot. See reference/publish-and-snapshot.md.
|
|
6
|
+
#
|
|
7
|
+
# Usage: snapshot-resources.sh <resources-root> <specs-root>
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
|
|
10
|
+
SRC="${1:?resources root}"; DST="${2:?specs root}"
|
|
11
|
+
REG_SRC="$SRC/Resources/Figma/Components"
|
|
12
|
+
SUG_SRC="$SRC/Resources/Localization/LocalizationStrings/Sources/Suggested"
|
|
13
|
+
REG_DST="$DST/resources/Figma/Components"
|
|
14
|
+
SUG_DST="$DST/resources/Localization/Suggested"
|
|
15
|
+
|
|
16
|
+
for d in "$REG_SRC" "$SUG_SRC"; do
|
|
17
|
+
[ -d "$d" ] || { echo "missing source dir: $d" >&2; exit 1; }
|
|
18
|
+
done
|
|
19
|
+
mkdir -p "$REG_DST" "$SUG_DST"
|
|
20
|
+
|
|
21
|
+
# --delete keeps the snapshot a faithful mirror (drops removed keys/components).
|
|
22
|
+
rsync -a --delete "$REG_SRC/" "$REG_DST/"
|
|
23
|
+
rsync -a --delete "$SUG_SRC/" "$SUG_DST/"
|
|
24
|
+
|
|
25
|
+
echo "snapshot: $(ls "$REG_DST" | wc -l | tr -d ' ') components, $(ls "$SUG_DST" | wc -l | tr -d ' ') suggested keys"
|
|
26
|
+
echo "→ $DST/resources (commit like a design-export refresh)"
|