@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.
Files changed (106) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +10 -6
  3. package/README.tr.md +143 -0
  4. package/docs/architecture.md +23 -8
  5. package/docs/ecosystem.md +237 -0
  6. package/install/_plugin-skills.mjs +16 -2
  7. package/install/codex.mjs +9 -4
  8. package/install/templates/copilot-instructions.md +12 -9
  9. package/package.json +1 -1
  10. package/pipeline/commands/multi-agent/SKILL.md +2 -2
  11. package/pipeline/commands/multi-agent/analysis/SKILL.md +2 -2
  12. package/pipeline/commands/multi-agent/autopilot/SKILL.md +6 -2
  13. package/pipeline/commands/multi-agent/channels/SKILL.md +15 -4
  14. package/pipeline/commands/multi-agent/create-jira/SKILL.md +4 -4
  15. package/pipeline/commands/multi-agent/dev/SKILL.md +10 -23
  16. package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +10 -2
  17. package/pipeline/commands/multi-agent/dev-local/SKILL.md +10 -24
  18. package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +10 -3
  19. package/pipeline/commands/multi-agent/jira/SKILL.md +13 -2
  20. package/pipeline/commands/multi-agent/language/SKILL.md +1 -1
  21. package/pipeline/commands/multi-agent/local/SKILL.md +6 -2
  22. package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +6 -2
  23. package/pipeline/commands/multi-agent/log/SKILL.md +7 -1
  24. package/pipeline/commands/multi-agent/ship/SKILL.md +5 -1
  25. package/pipeline/commands/multi-agent/sync/SKILL.md +3 -3
  26. package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
  27. package/pipeline/lib/extract-conventions.sh +44 -15
  28. package/pipeline/lib/fetch-figma-annotations.sh +8 -1
  29. package/pipeline/lib/fetch-fortify.sh +23 -8
  30. package/pipeline/lib/figma-screenshot.sh +11 -1
  31. package/pipeline/lib/issue-fetcher.sh +76 -9
  32. package/pipeline/lib/md2confluence-v3.py +16 -2
  33. package/pipeline/lib/plan-todos.sh +5 -2
  34. package/pipeline/lib/post-pr-review.sh +8 -6
  35. package/pipeline/lib/shadow-git.sh +50 -9
  36. package/pipeline/lib/submodule-detector.sh +8 -1
  37. package/pipeline/multi-agent-refs/_input-parser.md +1 -1
  38. package/pipeline/multi-agent-refs/channels/confluence.md +3 -0
  39. package/pipeline/multi-agent-refs/channels/issue-comment.md +2 -2
  40. package/pipeline/multi-agent-refs/channels/jira.md +13 -2
  41. package/pipeline/multi-agent-refs/channels/pr-review-actions.md +1 -1
  42. package/pipeline/multi-agent-refs/channels/pr.md +20 -0
  43. package/pipeline/multi-agent-refs/channels/wiki.md +1 -1
  44. package/pipeline/multi-agent-refs/cross-cli-contract.md +3 -3
  45. package/pipeline/multi-agent-refs/features/worktree-finalize.md +1 -1
  46. package/pipeline/multi-agent-refs/generate-issue.md +2 -2
  47. package/pipeline/multi-agent-refs/issue-jira-triad.md +3 -3
  48. package/pipeline/multi-agent-refs/payload-contracts.md +67 -0
  49. package/pipeline/multi-agent-refs/phases/modes.md +20 -0
  50. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  51. package/pipeline/multi-agent-refs/phases/phase-6-commit.md +8 -40
  52. package/pipeline/multi-agent-refs/phases/phase-7-report.md +5 -3
  53. package/pipeline/multi-agent-refs/phases.md +6 -0
  54. package/pipeline/multi-agent-refs/rules.md +2 -0
  55. package/pipeline/schemas/prefs.schema.json +1 -1
  56. package/pipeline/scripts/audit-log-rotate.sh +10 -0
  57. package/pipeline/scripts/build-stack-plugins.mjs +8 -1
  58. package/pipeline/scripts/check-derived-drift.mjs +13 -1
  59. package/pipeline/scripts/diff-explain.mjs +41 -3
  60. package/pipeline/scripts/diff-risk-score.mjs +72 -8
  61. package/pipeline/scripts/gen-mode-dispatch.mjs +1 -1
  62. package/pipeline/scripts/learning-curve.mjs +8 -2
  63. package/pipeline/scripts/output-quality-check.sh +15 -4
  64. package/pipeline/scripts/phase-tracker.sh +21 -8
  65. package/pipeline/scripts/pre-commit-check.sh +69 -22
  66. package/pipeline/scripts/render-agent-log-cost.sh +8 -3
  67. package/pipeline/scripts/render-cost-summary.sh +42 -22
  68. package/pipeline/scripts/render-work-summary.sh +47 -13
  69. package/pipeline/scripts/review-scope.mjs +1 -1
  70. package/pipeline/scripts/run-aggregator.mjs +38 -14
  71. package/pipeline/scripts/smoke-schema-validation.sh +5 -1
  72. package/pipeline/scripts/test-gap-scan.mjs +45 -6
  73. package/pipeline/scripts/uninstall.mjs +39 -4
  74. package/pipeline/scripts/update-issue-progress.sh +12 -16
  75. package/pipeline/scripts/worktree-finalize.sh +23 -2
  76. package/pipeline/skills/.skills-index.json +11 -2
  77. package/pipeline/skills/shared/README.md +1 -1
  78. package/pipeline/skills/shared/core/multi-agent-autopilot/SKILL.md +4 -0
  79. package/pipeline/skills/shared/core/multi-agent-create-jira/SKILL.md +1 -1
  80. package/pipeline/skills/shared/core/multi-agent-dev/SKILL.md +4 -17
  81. package/pipeline/skills/shared/core/multi-agent-dev-autopilot/SKILL.md +8 -0
  82. package/pipeline/skills/shared/core/multi-agent-dev-local/SKILL.md +5 -18
  83. package/pipeline/skills/shared/core/multi-agent-dev-local-autopilot/SKILL.md +8 -0
  84. package/pipeline/skills/shared/core/multi-agent-language/SKILL.md +1 -1
  85. package/pipeline/skills/shared/core/multi-agent-local/SKILL.md +4 -0
  86. package/pipeline/skills/shared/core/multi-agent-local-autopilot/SKILL.md +4 -0
  87. package/pipeline/skills/shared/core/multi-agent-ship/SKILL.md +4 -0
  88. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +2 -2
  89. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +302 -0
  90. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +144 -0
  91. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +156 -0
  92. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +108 -0
  93. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +175 -0
  94. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +865 -0
  95. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +335 -0
  96. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +344 -0
  97. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +130 -0
  98. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +264 -0
  99. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +298 -0
  100. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +529 -0
  101. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +187 -0
  102. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +171 -0
  103. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +184 -0
  104. package/pipeline/skills/shared/external/localization-reuse-map/scripts/snapshot-resources.sh +26 -0
  105. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +173 -0
  106. package/pipeline/skills/skills-index.md +3 -2
@@ -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)"