@mmerterden/multi-agent-pipeline 20.8.0 → 20.8.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 (112) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/docs/facts.json +3 -3
  3. package/index.js +1 -0
  4. package/install/_codex-agents.mjs +1 -1
  5. package/install/_common.mjs +486 -53
  6. package/install/_mcp-register.mjs +173 -117
  7. package/install/claude.mjs +281 -220
  8. package/install/codex.mjs +7 -7
  9. package/install/copilot.mjs +13 -11
  10. package/install/index.mjs +92 -27
  11. package/install/templates/claude-hooks.json +9 -9
  12. package/manifest.json +112 -114
  13. package/package.json +5 -2
  14. package/pipeline/commands/multi-agent/update/SKILL.md +28 -17
  15. package/pipeline/lib/confusables.json +79 -33
  16. package/pipeline/lib/extract-conventions.sh +3 -3
  17. package/pipeline/lib/json-file-lock.mjs +27 -7
  18. package/pipeline/lib/normalize-text.mjs +86 -17
  19. package/pipeline/lib/outbound-gate.mjs +13 -4
  20. package/pipeline/lib/redact.mjs +87 -13
  21. package/pipeline/multi-agent-refs/analysis/evidence.md +1 -1
  22. package/pipeline/multi-agent-refs/analysis/synthesis.md +1 -1
  23. package/pipeline/multi-agent-refs/component-dispatch.md +1 -1
  24. package/pipeline/multi-agent-refs/conventions-defaults.md +1 -1
  25. package/pipeline/multi-agent-refs/features/unattended-security.md +2 -2
  26. package/pipeline/scripts/agent-guard.py +150 -25
  27. package/pipeline/scripts/audit-log.sh +3 -4
  28. package/pipeline/scripts/autopilot-runner.mjs +14 -5
  29. package/pipeline/scripts/doctor.mjs +8 -2
  30. package/pipeline/scripts/gen-skills-index.mjs +13 -1
  31. package/pipeline/scripts/log-metric.sh +9 -3
  32. package/pipeline/scripts/migrate-prefs.mjs +18 -4
  33. package/pipeline/scripts/pre-commit-check.sh +119 -27
  34. package/pipeline/scripts/scan-agent-config.sh +9 -9
  35. package/pipeline/scripts/unattended_policy.py +12 -3
  36. package/pipeline/scripts/uninstall.mjs +88 -1
  37. package/pipeline/scripts/usage-identity.mjs +1 -1
  38. package/pipeline/scripts/usage-register.mjs +1 -1
  39. package/pipeline/skills/.skill-manifest.json +30 -30
  40. package/pipeline/skills/shared/README.md +64 -64
  41. package/pipeline/skills/shared/external/alarmkit/SKILL.md +2 -2
  42. package/pipeline/skills/shared/external/alarmkit/evals/evals.json +2 -2
  43. package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +6 -0
  44. package/pipeline/skills/shared/external/app-store-optimization/references/keyword-research-methodology.md +3 -0
  45. package/pipeline/skills/shared/external/app-store-optimization/references/product-page-variants.md +3 -0
  46. package/pipeline/skills/shared/external/app-store-review/SKILL.md +3 -4
  47. package/pipeline/skills/shared/external/apple-on-device-ai/SKILL.md +5 -3
  48. package/pipeline/skills/shared/external/authentication/SKILL.md +29 -17
  49. package/pipeline/skills/shared/external/authentication/references/keychain-biometric.md +3 -1
  50. package/pipeline/skills/shared/external/background-processing/SKILL.md +10 -8
  51. package/pipeline/skills/shared/external/background-processing/references/background-task-patterns.md +7 -7
  52. package/pipeline/skills/shared/external/callkit-voip/SKILL.md +6 -3
  53. package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +43 -0
  54. package/pipeline/skills/shared/external/core-bluetooth/SKILL.md +4 -2
  55. package/pipeline/skills/shared/external/core-data/SKILL.md +12 -2
  56. package/pipeline/skills/shared/external/core-nfc/SKILL.md +31 -0
  57. package/pipeline/skills/shared/external/coreml/SKILL.md +1 -1
  58. package/pipeline/skills/shared/external/cryptokit/SKILL.md +1 -1
  59. package/pipeline/skills/shared/external/device-integrity/SKILL.md +11 -5
  60. package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
  61. package/pipeline/skills/shared/external/ios-module-structure/modules/_TEMPLATE.yml +1 -1
  62. package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +6 -6
  63. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +3 -2
  64. package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +20 -18
  65. package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +4 -4
  66. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +12 -9
  67. package/pipeline/skills/shared/external/localization-reuse-map/scripts/_shared.py +201 -0
  68. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +64 -30
  69. package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +14 -16
  70. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +20 -12
  71. package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +7 -7
  72. package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +26 -12
  73. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +4 -3
  74. package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +51 -24
  75. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +19 -9
  76. package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +26 -19
  77. package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +39 -46
  78. package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +37 -63
  79. package/pipeline/skills/shared/external/mapkit-location/SKILL.md +4 -2
  80. package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +5 -4
  81. package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +3 -2
  82. package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +2 -2
  83. package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +1 -1
  84. package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +4 -4
  85. package/pipeline/skills/shared/external/permissionkit/SKILL.md +15 -6
  86. package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +2 -1
  87. package/pipeline/skills/shared/external/push-notifications/SKILL.md +8 -4
  88. package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +1 -1
  89. package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +25 -6
  90. package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +1 -1
  91. package/pipeline/skills/shared/external/skill-creator/template.md +7 -1
  92. package/pipeline/skills/shared/external/storekit/references/core-patterns.md +6 -1
  93. package/pipeline/skills/shared/external/swift-concurrency/SKILL.md +3 -2
  94. package/pipeline/skills/shared/external/swift-concurrency/references/concurrency-patterns.md +1 -1
  95. package/pipeline/skills/shared/external/swift-security/SKILL.md +10 -8
  96. package/pipeline/skills/shared/external/swift-security/references/certificate-trust.md +8 -5
  97. package/pipeline/skills/shared/external/swift-security/references/keychain-fundamentals.md +13 -9
  98. package/pipeline/skills/shared/external/swift-security/references/secure-enclave.md +7 -6
  99. package/pipeline/skills/shared/external/swift-testing/SKILL.md +8 -5
  100. package/pipeline/skills/shared/external/swift-testing/evals/evals.json +1 -1
  101. package/pipeline/skills/shared/external/swift-testing/references/testing-advanced.md +3 -2
  102. package/pipeline/skills/shared/external/swiftdata/SKILL.md +5 -3
  103. package/pipeline/skills/shared/external/swiftui-navigation/SKILL.md +17 -9
  104. package/pipeline/skills/shared/external/swiftui-navigation/references/navigationstack.md +4 -2
  105. package/pipeline/skills/shared/external/swiftui-navigation/references/tabview.md +13 -6
  106. package/pipeline/skills/shared/external/vision-framework/SKILL.md +3 -1
  107. package/pipeline/skills/shared/external/weatherkit/SKILL.md +8 -5
  108. package/pipeline/skills/shared/external/widgetkit/SKILL.md +15 -7
  109. package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +8 -6
  110. package/pipeline/scripts/gen-ref-toc.mjs +0 -279
  111. package/pipeline/scripts/make-manifest.mjs +0 -199
  112. package/pipeline/scripts/scorecard-snapshot.mjs +0 -178
@@ -0,0 +1,201 @@
1
+ """Key patterns, mapping-row marker rules and text helpers shared by the scripts.
2
+
3
+ Every script that scans keys, names output files or orders languages imports
4
+ this module, so the scanner, the gate, the CMS sheet and the overlay agree on
5
+ what a wired key is, which rows are component-owned or dynamic, which files are
6
+ source, how a screen name becomes a file slug and which order languages take.
7
+ The key accessor type (`--key-type`, default `L10n`) and the language list
8
+ (`--langs`, default the languages present in the data) are inputs, never a
9
+ built-in list of one app's.
10
+ """
11
+ import argparse
12
+ import os
13
+ import re
14
+
15
+ EXTENSIONS = {".swift", ".kt", ".kts", ".xml", ".ts", ".tsx", ".js", ".jsx", ".vue"}
16
+ SKIP_DIRS = {".git", "Generated"}
17
+ FALLBACK_LANG = "en"
18
+ RTL_LANGS = {"ar", "fa", "he", "ur"}
19
+
20
+ WORD = "A-Za-z0-9_"
21
+ IDENT = rf"[A-Za-z_][{WORD}]*"
22
+ DEFAULT_KEY_TYPES = ("L10n",)
23
+ KEY_TYPE_NAME = re.compile(rf"^{IDENT}$")
24
+ LITERAL_KEY = re.compile(rf"\"([{WORD}.]+)\"\.localized\b")
25
+ WEB_T = re.compile(r"(?<![\w$])t\(\s*(['\"])([A-Za-z0-9_.\-]+)\1")
26
+ WEB_DOLLAR_T = re.compile(r"\$t\(\s*(['\"])([A-Za-z0-9_.\-]+)\1")
27
+ INTERPOLATION = re.compile(r"\\\(|\$\{|\$[A-Za-z_]")
28
+
29
+ TRAILING_MARKER = re.compile(r"\s*\([^()]*\)\s*$")
30
+ COMPONENT_MARKER = re.compile(r"\([^)]*(owned by|bile\u015fen|component)[^)]*\)", re.IGNORECASE)
31
+ PLACEHOLDER = re.compile(r"<[^<>]+>")
32
+ DYNAMIC_WORDS = ("dynamic", "dinamik")
33
+ RUNTIME_NOTE = "enumerate at runtime"
34
+
35
+ XML_INVALID = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f]")
36
+
37
+ ACCESSOR_EXTENSIONS = {".swift", ".kt", ".kts"}
38
+ COMMON_ACCESSORS = (
39
+ "L10n", "LocalizationKey", "LocalizationKeys",
40
+ "LocalizedStringKey", "LocalizedStrings", "Localizable", "Localization", "Strings", "Loc",
41
+ )
42
+ ACCESSOR_CHAIN = re.compile(rf"(?<![{WORD}.])([A-Z][{WORD}]*)((?:\.{IDENT}){{2,}})")
43
+
44
+
45
+ def strip_accessor(chain):
46
+ segments = chain.strip(".").split(".")
47
+ while segments and segments[-1].startswith("localized"):
48
+ segments.pop()
49
+ return ".".join(segments)
50
+
51
+
52
+ def key_types(values, fallback=DEFAULT_KEY_TYPES):
53
+ """The accessor type names from repeated or comma-separated --key-type values.
54
+
55
+ Raises ValueError on a name that is not an identifier.
56
+ """
57
+ names = [part.strip() for value in values or [] for part in str(value).split(",") if part.strip()]
58
+ for name in names:
59
+ if not KEY_TYPE_NAME.match(name):
60
+ raise ValueError(f"--key-type expects an identifier, got {name!r}")
61
+ return tuple(dict.fromkeys(names)) or tuple(fallback)
62
+
63
+
64
+ def key_type_alternation(types):
65
+ return "|".join(re.escape(name) for name in sorted(types, key=len, reverse=True))
66
+
67
+
68
+ def key_chain_pattern(types):
69
+ return re.compile(rf"(?:{key_type_alternation(types)})((?:\.{IDENT})+)")
70
+
71
+
72
+ def lsk_keys(text, types=DEFAULT_KEY_TYPES):
73
+ for hit in key_chain_pattern(types).finditer(text):
74
+ key = strip_accessor(hit.group(1))
75
+ if key.count(".") >= 1:
76
+ yield key
77
+
78
+
79
+ def accessor_roots(texts, types=DEFAULT_KEY_TYPES):
80
+ """Roots of key-like member chains that are not one of `types`, with their counts.
81
+
82
+ A chain counts when its root is a common accessor name or when it ends in
83
+ `.localized`, and it keeps two or more segments once that suffix is dropped.
84
+ """
85
+ counts = {}
86
+ for text in texts:
87
+ for hit in ACCESSOR_CHAIN.finditer(text):
88
+ root, members = hit.group(1), hit.group(2)
89
+ if root in types:
90
+ continue
91
+ segments = members.strip(".").split(".")
92
+ localized = segments[-1].startswith("localized")
93
+ if strip_accessor(members).count(".") < 1:
94
+ continue
95
+ if root in COMMON_ACCESSORS or localized:
96
+ counts[root] = counts.get(root, 0) + 1
97
+ return dict(sorted(counts.items(), key=lambda item: (-item[1], item[0])))
98
+
99
+
100
+ def key_type_warning(texts, types=DEFAULT_KEY_TYPES, mapping_hint=False):
101
+ """A warning when no chain of `types` is found but chains of another accessor are.
102
+
103
+ `texts` are the Swift and Kotlin sources read. Returns None when the
104
+ configured type matched at least once or nothing resembles an accessor.
105
+ """
106
+ texts = list(texts)
107
+ if any(True for text in texts for _ in lsk_keys(text, types)):
108
+ return None
109
+ roots = accessor_roots(texts, types)
110
+ if not roots:
111
+ return None
112
+ seen = ", ".join(f"{root} ({count})" for root, count in list(roots.items())[:5])
113
+ where = "--key-type <Name> (or \"keyType\" in the mapping)" if mapping_hint else "--key-type <Name>"
114
+ return (
115
+ f"WARNING: no key chain of {', '.join(types)} was found, but the code has accessor chains rooted at "
116
+ f"{seen}. Set {where} to the project's key accessor type; until then its keys are "
117
+ f"missed or counted as weak."
118
+ )
119
+
120
+
121
+ def order_langs(langs):
122
+ """en first when present, then the rest sorted; en alone when nothing is given."""
123
+ unique = list(dict.fromkeys(lang for lang in langs if lang))
124
+ rest = sorted(lang for lang in unique if lang != FALLBACK_LANG)
125
+ ordered = ([FALLBACK_LANG] if FALLBACK_LANG in unique else []) + rest
126
+ return ordered or [FALLBACK_LANG]
127
+
128
+
129
+ def parse_langs(value, present=()):
130
+ """A comma list in the order given, or for 'all' every language in `present`."""
131
+ text = str(value or "all").strip()
132
+ if text == "all":
133
+ return order_langs(present)
134
+ return [part.strip() for part in text.split(",") if part.strip()]
135
+
136
+
137
+ def reject_token_flag(cli, opts, env_hint):
138
+ """Exit 2 when a credential was passed on argv, which other processes can read."""
139
+ if getattr(opts, "token", None) is not None:
140
+ cli.error(f"--token is not accepted, because other processes can read argv; {env_hint}")
141
+
142
+
143
+ def add_token_flag(cli):
144
+ cli.add_argument("--token", help=argparse.SUPPRESS)
145
+
146
+
147
+ def is_interpolated(literal):
148
+ return bool(INTERPOLATION.search(literal))
149
+
150
+
151
+ def normalize_key(key):
152
+ return ".".join(seg[:1].upper() + seg[1:] for seg in key.split("."))
153
+
154
+
155
+ def source_files(root):
156
+ if os.path.isfile(root):
157
+ yield root
158
+ return
159
+ for current, dirs, files in os.walk(root):
160
+ relative = os.path.relpath(current, root)
161
+ parts = [] if relative == os.curdir else relative.split(os.sep)
162
+ if SKIP_DIRS.intersection(parts):
163
+ dirs[:] = []
164
+ continue
165
+ for name in sorted(files):
166
+ if os.path.splitext(name)[1] in EXTENSIONS:
167
+ yield os.path.join(current, name)
168
+
169
+
170
+ def raw_key(row):
171
+ return str(row.get("newKey") or "").strip()
172
+
173
+
174
+ def clean_key(new_key):
175
+ text = str(new_key or "").strip()
176
+ if text.startswith("(dynamic"):
177
+ return text
178
+ return TRAILING_MARKER.sub("", text)
179
+
180
+
181
+ def is_component(row):
182
+ return bool(COMPONENT_MARKER.search(raw_key(row)))
183
+
184
+
185
+ def key_is_dynamic(key):
186
+ return key.startswith("(dynamic") or bool(PLACEHOLDER.search(key))
187
+
188
+
189
+ def is_dynamic(row, key):
190
+ element = str(row.get("element") or "").lower()
191
+ note = str(row.get("note") or "").lower()
192
+ return key_is_dynamic(key) or any(word in element for word in DYNAMIC_WORDS) or RUNTIME_NOTE in note
193
+
194
+
195
+ def xml_text(value):
196
+ return XML_INVALID.sub("", str(value))
197
+
198
+
199
+ def slug(text):
200
+ value = re.sub(r"[\W_]+", "-", str(text or "").strip().lower()).strip("-")
201
+ return value or "screen"
@@ -11,6 +11,7 @@ import html
11
11
  import json
12
12
  import os
13
13
  import re
14
+ import shlex
14
15
  import shutil
15
16
  import struct
16
17
  import subprocess
@@ -22,7 +23,8 @@ import zipfile
22
23
  from collections import namedtuple
23
24
  from xml.sax.saxutils import escape, quoteattr
24
25
 
25
- LANGS = ["en", "tr", "ar"] + sorted(["ru", "it", "fr", "es", "de"])
26
+ from _shared import RTL_LANGS, order_langs, parse_langs, slug, xml_text
27
+
26
28
  WARN = "\u26a0\ufe0f"
27
29
  DASH = "-"
28
30
  VERDICTS = ("reuse", "review", "new")
@@ -120,11 +122,6 @@ def look(row):
120
122
  return LOOKS.get(row.get("verdict"), UNKNOWN_LOOK)
121
123
 
122
124
 
123
- def slug(text):
124
- value = re.sub(r"[\W_]+", "-", str(text or "").strip().lower()).strip("-")
125
- return value or "screen"
126
-
127
-
128
125
  def norm(value):
129
126
  text = unicodedata.normalize("NFC", str(value or "")).strip()
130
127
  text = re.sub(r"\s+", " ", text)
@@ -189,8 +186,12 @@ def load_keyshots(mapping, mapping_dir, out_dir):
189
186
  return shots
190
187
 
191
188
 
189
+ def xml_escape(value):
190
+ return escape(xml_text(value))
191
+
192
+
192
193
  def node(name, attrs=(), *children):
193
- opening = name + "".join(f" {k}={quoteattr(str(v))}" for k, v in attrs)
194
+ opening = name + "".join(f" {k}={quoteattr(xml_text(v))}" for k, v in attrs)
194
195
  if not children:
195
196
  return f"<{opening}/>"
196
197
  return f"<{opening}>{''.join(children)}</{name}>"
@@ -209,14 +210,23 @@ def storage_image(filename, dimension, pixels):
209
210
  return node("ac:image", [(f"ac:{dimension}", pixels)], target)
210
211
 
211
212
 
213
+ def mapping_langs(rows):
214
+ found = []
215
+ for row in rows:
216
+ for side in ("new", "legacy", "cms"):
217
+ found.extend((row.get(side) or {}).keys())
218
+ return order_langs(found)
219
+
220
+
212
221
  class Doc:
213
- def __init__(self, mapping, t, prefix, shots, out_dir):
222
+ def __init__(self, mapping, t, prefix, shots, out_dir, langs=None):
214
223
  self.m = mapping
215
224
  self.t = t
216
225
  self.prefix = prefix
217
226
  self.shots = shots
218
227
  self.out = out_dir
219
228
  self.rows = mapping.get("rows") or []
229
+ self.langs = list(langs) if langs else mapping_langs(self.rows)
220
230
  self.counts = {v: sum(1 for r in self.rows if r.get("verdict") == v) for v in VERDICTS}
221
231
  self.cms_rows = sum(1 for r in self.rows if (r.get("cms") or {}).get("tr") or (r.get("cms") or {}).get("en"))
222
232
  self.drift_rows = sum(1 for r in self.rows if row_drifts(r))
@@ -245,7 +255,7 @@ class Doc:
245
255
  ]
246
256
 
247
257
  def detail_langs(self, row):
248
- return LANGS + [lang for lang in (row.get("cms") or {}) if lang not in LANGS]
258
+ return self.langs + [lang for lang in (row.get("cms") or {}) if lang not in self.langs]
249
259
 
250
260
  def md(self):
251
261
  t, m = self.t, self.m
@@ -291,12 +301,12 @@ class Doc:
291
301
  return storage_macro(
292
302
  "status",
293
303
  node("ac:parameter", [("ac:name", "colour")], look(row).status),
294
- node("ac:parameter", [("ac:name", "title")], escape(self.verdict(row))),
304
+ node("ac:parameter", [("ac:name", "title")], xml_escape(self.verdict(row))),
295
305
  )
296
306
 
297
307
  def xml(self):
298
308
  t, m = self.t, self.m
299
- e = escape
309
+ e = xml_escape
300
310
 
301
311
  def heading(label):
302
312
  return f"<h2>{e(t[label])}</h2>"
@@ -504,7 +514,7 @@ def para(text, bold=False, size=None, rtl=False):
504
514
  if rtl:
505
515
  styling.append(w("rtl"))
506
516
  run = [w("rPr", *styling)] if styling else []
507
- run.append(node("w:t", [("xml:space", "preserve")], escape(str(text))))
517
+ run.append(node("w:t", [("xml:space", "preserve")], xml_escape(text)))
508
518
  lead = [w("pPr", w("bidi"))] if rtl else []
509
519
  return w("p", *lead, w("r", *run))
510
520
 
@@ -571,7 +581,7 @@ def detail_blocks(doc, t):
571
581
  blocks.append(para(f"{t['key']}: {row.get('newKey') or ''}"))
572
582
  lines = [table_row(tcell(c, fill="F5F6F8") for c in t["detail_columns"])]
573
583
  for lang in doc.detail_langs(row):
574
- lines.append(table_row(tcell(v, rtl=lang == "ar") for v in (lang, *lang_values(row, lang))))
584
+ lines.append(table_row(tcell(v, rtl=lang in RTL_LANGS) for v in (lang, *lang_values(row, lang))))
575
585
  blocks.append(table(lines))
576
586
  return blocks
577
587
 
@@ -663,17 +673,21 @@ def chrome_candidates():
663
673
 
664
674
  def pdf_libreoffice(docx_path, out_dir):
665
675
  binaries = [shutil.which("soffice"), shutil.which("libreoffice"), mac_app("LibreOffice", "soffice")]
666
- expected = os.path.splitext(docx_path)[0] + ".pdf"
676
+ name = os.path.splitext(os.path.basename(docx_path))[0] + ".pdf"
667
677
  for binary in binaries:
668
678
  if not (binary and os.path.isfile(binary)):
669
679
  continue
670
- try:
671
- subprocess.run([binary, "--headless", "--convert-to", "pdf", "--outdir", out_dir, docx_path],
672
- capture_output=True, timeout=180)
673
- except (OSError, subprocess.SubprocessError):
674
- continue
675
- if os.path.isfile(expected) and os.path.getsize(expected) > 0:
676
- return expected
680
+ with tempfile.TemporaryDirectory() as scratch:
681
+ try:
682
+ subprocess.run([binary, "--headless", "--convert-to", "pdf", "--outdir", scratch, docx_path],
683
+ capture_output=True, timeout=180)
684
+ except (OSError, subprocess.SubprocessError):
685
+ continue
686
+ rendered = os.path.join(scratch, name)
687
+ if os.path.isfile(rendered) and os.path.getsize(rendered) > 0:
688
+ target = os.path.join(out_dir, name)
689
+ shutil.copyfile(rendered, target)
690
+ return target
677
691
  return None
678
692
 
679
693
 
@@ -698,7 +712,7 @@ def pdf_chrome(preview_path, pdf_path):
698
712
  with tempfile.TemporaryDirectory() as profile:
699
713
  if os.path.exists(pdf_path):
700
714
  os.remove(pdf_path)
701
- argv = [chrome, headless, "--disable-gpu", f"--user-data-dir={profile}", "--no-pdf-header-footer",
715
+ argv = [chrome, headless, "--no-sandbox", "--disable-gpu", f"--user-data-dir={profile}", "--no-pdf-header-footer",
702
716
  f"--print-to-pdf={pdf_path}", page]
703
717
  try:
704
718
  proc = subprocess.Popen(argv, stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
@@ -721,17 +735,31 @@ def pdf_wkhtml(preview_path, pdf_path):
721
735
  return pdf_path if os.path.isfile(pdf_path) and os.path.getsize(pdf_path) > 0 else None
722
736
 
723
737
 
724
- def print_upload(name, mapping, screen):
738
+ def upload_path(name, mapping_dir, out_dir):
739
+ if os.path.isabs(name):
740
+ return name
741
+ for folder in (mapping_dir, out_dir):
742
+ if os.path.exists(os.path.join(folder, name)):
743
+ return os.path.join(folder, name)
744
+ return os.path.join(out_dir, name)
745
+
746
+
747
+ def print_upload(name, mapping, screen, mapping_dir, out_dir):
725
748
  prefix = os.environ.get("LOCALIZATION_PAGE_PREFIX", "Localization")
726
- flags = [f"--xml {name}.confluence.xml", f'--screen "{screen}"']
727
- flags += [f"--{field} {mapping[field]}" for field in ("screenshot", "overlay", "keyshots") if mapping.get(field)]
728
- flags.append(f"--attach {name}.localization.xlsx")
749
+ quote = shlex.quote
750
+ flags = [f"--xml {quote(os.path.join(out_dir, name + '.confluence.xml'))}", f"--screen {quote(str(screen))}"]
751
+ flags += [
752
+ f"--{field} {quote(upload_path(str(mapping[field]), mapping_dir, out_dir))}"
753
+ for field in ("screenshot", "overlay", "keyshots") if mapping.get(field)
754
+ ]
755
+ flags.append(f"--attach {quote(os.path.join(out_dir, name + '.localization.xlsx'))}")
729
756
  print(" \\\n ".join(["python3 scripts/publish-confluence.py"] + flags))
730
757
  print()
758
+ title = json.dumps(f"{prefix} - {screen}", ensure_ascii=False).replace("'", "'\\''")
731
759
  print("# The same call by hand, for Server/DC with a Bearer PAT read from the keychain (argv never sees the token):")
732
- print("# printf 'header = \"Authorization: Bearer %s\"\\n' \"$(security find-generic-password -s CONFLUENCE_API_TOKEN -w)\" > auth.curlrc; chmod 600 auth.curlrc")
760
+ print("# (umask 077 && rm -f auth.curlrc && printf 'header = \"Authorization: Bearer %s\"\\n' \"$(security find-generic-password -s CONFLUENCE_API_TOKEN -w)\" > auth.curlrc)")
733
761
  print("# curl -sS -K auth.curlrc -X POST -H 'Content-Type: application/json' \"$CONFLUENCE_BASE_URL/rest/api/content\" \\")
734
- print(f"# --data '{{\"type\":\"page\",\"title\":\"{prefix} - {screen}\",\"space\":{{\"key\":\"'$CONFLUENCE_SPACE'\"}},"
762
+ print(f"# --data '{{\"type\":\"page\",\"title\":{title},\"space\":{{\"key\":\"'$CONFLUENCE_SPACE'\"}},"
735
763
  "\"ancestors\":[{\"id\":\"'$CONFLUENCE_PARENT'\"}],\"body\":{\"storage\":{\"value\":\"...\",\"representation\":\"storage\"}}}'")
736
764
  print("# rm auth.curlrc")
737
765
 
@@ -766,6 +794,11 @@ def main():
766
794
  cli.add_argument("--pdf", action="store_true", help="add a PDF to the outputs")
767
795
  cli.add_argument("--all", action="store_true", help="shorthand for --docx plus --pdf")
768
796
  cli.add_argument("--print-upload", action="store_true", help="show the publish command and a hand-rolled curl version of it")
797
+ cli.add_argument(
798
+ "--langs", default="all",
799
+ help="languages of the details tables, comma list in display order; 'all' (default) takes every language "
800
+ "in the mapping, en first then the rest sorted",
801
+ )
769
802
  opts = cli.parse_args()
770
803
 
771
804
  mapping, mapping_dir = read_mapping(opts.mapping)
@@ -776,7 +809,8 @@ def main():
776
809
  out_dir = os.path.abspath(opts.target_dir)
777
810
  os.makedirs(out_dir, exist_ok=True)
778
811
  shots = load_keyshots(mapping, mapping_dir, out_dir)
779
- doc = Doc(mapping, T[opts.ui_lang], mapping.get("legacyKeyPrefix") or "", shots, out_dir)
812
+ langs = None if opts.langs.strip() == "all" else parse_langs(opts.langs)
813
+ doc = Doc(mapping, T[opts.ui_lang], mapping.get("legacyKeyPrefix") or "", shots, out_dir, langs)
780
814
  doc.base = mapping_dir
781
815
 
782
816
  renderers = [("md", doc.md), ("confluence.xml", doc.xml), ("preview.html", doc.preview)]
@@ -797,7 +831,7 @@ def main():
797
831
  make_pdf(docx_path, out_dir, stem, keep_docx)
798
832
 
799
833
  if opts.print_upload:
800
- print_upload(stem, mapping, mapping.get("screen"))
834
+ print_upload(stem, mapping, mapping.get("screen"), mapping_dir, out_dir)
801
835
 
802
836
 
803
837
  if __name__ == "__main__":
@@ -15,6 +15,8 @@ import sys
15
15
  import zipfile
16
16
  from xml.sax.saxutils import escape
17
17
 
18
+ from _shared import clean_key, key_is_dynamic, slug, xml_text
19
+
18
20
  COLUMNS = [
19
21
  "Channel", "Property Group", "Property Module", "Key",
20
22
  "EN Value", "TR Value", "AR Value", "Anotation EN", "Anotation TR",
@@ -37,11 +39,6 @@ DEFAULT_TAXONOMY = {
37
39
  }
38
40
 
39
41
 
40
- def slug(text):
41
- value = re.sub(r"[\W_]+", "-", str(text or "").strip().lower()).strip("-")
42
- return value or "screen"
43
-
44
-
45
42
  def read_json(source):
46
43
  with open(source, encoding="utf-8") as stream:
47
44
  return json.load(stream)
@@ -62,12 +59,6 @@ def load_taxonomy(path):
62
59
  return taxonomy
63
60
 
64
61
 
65
- def clean_key(new_key):
66
- text = str(new_key or "")
67
- cut = text.find("(owned by")
68
- return (text[:cut] if cut >= 0 else text).strip()
69
-
70
-
71
62
  def bucket(key, taxonomy):
72
63
  lowered = key.lower()
73
64
  if "error" in lowered:
@@ -88,9 +79,12 @@ def bucket(key, taxonomy):
88
79
 
89
80
 
90
81
  def table_rows(mapping, channel, taxonomy):
91
- out = []
92
- for row in mapping.get("rows") or []:
82
+ out, skipped = [], []
83
+ for number, row in enumerate(mapping.get("rows") or [], 1):
93
84
  key = clean_key(row.get("newKey"))
85
+ if not key or key_is_dynamic(key):
86
+ skipped.append((number, key))
87
+ continue
94
88
  group, module = bucket(key, taxonomy)
95
89
  if row.get("propertyGroup"):
96
90
  group = row["propertyGroup"]
@@ -103,7 +97,7 @@ def table_rows(mapping, channel, taxonomy):
103
97
  new.get("en") or "", new.get("tr") or "", new.get("ar") or "",
104
98
  cms.get("en") or "", cms.get("tr") or "",
105
99
  ])
106
- return out
100
+ return out, skipped
107
101
 
108
102
 
109
103
  def column_letter(index):
@@ -137,7 +131,7 @@ def part(root, attrs, *children):
137
131
 
138
132
 
139
133
  def cell(ref, value, style):
140
- text = escape(str(value)).replace("\n", "&#10;")
134
+ text = escape(xml_text(value)).replace("\n", "&#10;")
141
135
  attrs = [("r", ref), ("t", "inlineStr")]
142
136
  if style:
143
137
  attrs.append(("s", style))
@@ -276,7 +270,7 @@ def main():
276
270
  if not isinstance(mapping, dict) or "rows" not in mapping:
277
271
  cli.error("mapping must be a JSON object with rows")
278
272
 
279
- rows = table_rows(mapping, opts.channel, load_taxonomy(opts.taxonomy))
273
+ rows, skipped = table_rows(mapping, opts.channel, load_taxonomy(opts.taxonomy))
280
274
  stem = opts.slug or slug(mapping.get("screen"))
281
275
  os.makedirs(opts.target_dir, exist_ok=True)
282
276
 
@@ -294,6 +288,10 @@ def main():
294
288
  summary = {"slug": stem, "rows": len(rows), "columns": COLUMNS, "channel": opts.channel, "written": produced}
295
289
  json.dump(summary, sys.stdout, indent=2, ensure_ascii=False)
296
290
  sys.stdout.write("\n")
291
+ if skipped:
292
+ print(f"# {len(skipped)} row(s) left out of the sheet: no literal key (empty, dynamic or <placeholder>)", file=sys.stderr)
293
+ for number, key in skipped:
294
+ print(f"# row {number}: {key or '(empty)'}", file=sys.stderr)
297
295
 
298
296
 
299
297
  if __name__ == "__main__":
@@ -17,6 +17,8 @@ import urllib.parse
17
17
  import urllib.request
18
18
  from datetime import datetime, timezone
19
19
 
20
+ from _shared import add_token_flag, reject_token_flag
21
+
20
22
  API = "https://api.figma.com/v1"
21
23
  BATCH = 4
22
24
  SEP = "[:\\-\u2013]"
@@ -24,8 +26,12 @@ TR_RE = re.compile("^(?:tr|tr[kc]e|t[\u00fcu]rk[\u00e7c]e)\\s*" + SEP + "\\s*(.+
24
26
  EN_RE = re.compile("^(?:en|eng|ing|[i\u0130]ngilizce)\\s*" + SEP + "\\s*(.+)$", re.IGNORECASE)
25
27
 
26
28
 
29
+ URL_NODE_ID = re.compile(r"^\d+-\d+$")
30
+
31
+
27
32
  def canon_id(node_id):
28
- return str(node_id).strip().replace("-", ":")
33
+ text = "" if node_id is None else str(node_id).strip()
34
+ return text.replace("-", ":") if URL_NODE_ID.match(text) else text
29
35
 
30
36
 
31
37
  def split_lang(text):
@@ -68,7 +74,7 @@ def pick_label(labels):
68
74
  def record(node_id, name, node_type, design_text, labels):
69
75
  raw, parsed = pick_label(labels)
70
76
  return {
71
- "nodeId": canon_id(node_id),
77
+ "nodeId": canon_id(node_id) or None,
72
78
  "name": name,
73
79
  "type": node_type,
74
80
  "designText": design_text,
@@ -113,9 +119,7 @@ def keychain_secret(service):
113
119
  return secret if lookup.returncode == 0 and secret else None
114
120
 
115
121
 
116
- def figma_token(explicit):
117
- if explicit:
118
- return explicit
122
+ def figma_token():
119
123
  from_env = next((os.environ[v] for v in ("FIGMA_ACCESS_TOKEN", "FIGMA_TOKEN") if os.environ.get(v)), None)
120
124
  return from_env or keychain_secret("FIGMA_ACCESS_TOKEN")
121
125
 
@@ -206,11 +210,11 @@ def pick_source(opts, cli, file_key, nodes):
206
210
  if opts.source == "rest":
207
211
  if not have_target:
208
212
  cli.error("--source rest needs a file key and node-ids (--file/--nodes or --mapping)")
209
- token = figma_token(opts.token)
213
+ token = figma_token()
210
214
  if not token:
211
- sys.exit("No Figma token found. Export FIGMA_ACCESS_TOKEN or FIGMA_TOKEN, keep it in the keychain under FIGMA_ACCESS_TOKEN, or use --token.")
215
+ sys.exit("No Figma token found. Export FIGMA_ACCESS_TOKEN or FIGMA_TOKEN, or keep it in the keychain under FIGMA_ACCESS_TOKEN.")
212
216
  return ("rest",) + from_rest(file_key, nodes, token, opts.ca_file)
213
- token = figma_token(opts.token) if have_target else None
217
+ token = figma_token() if have_target else None
214
218
  if have_target and token:
215
219
  try:
216
220
  return ("rest",) + from_rest(file_key, nodes, token, opts.ca_file)
@@ -232,10 +236,11 @@ def main():
232
236
  cli.add_argument("--from-mcp", help="JSON dump of annotations taken through MCP")
233
237
  cli.add_argument("--local", help="screen folder of a design export that contains tree.json")
234
238
  cli.add_argument("--source", default="auto", choices=["auto", "rest", "mcp", "local"])
235
- cli.add_argument("--token", help="Figma token; FIGMA_ACCESS_TOKEN or the keychain is safer since other processes can read argv")
239
+ add_token_flag(cli)
236
240
  cli.add_argument("--ca-file", help="PEM roots to trust when a proxy intercepts TLS")
237
241
  cli.add_argument("--out", help="file to write; stdout when omitted")
238
242
  opts = cli.parse_args()
243
+ reject_token_flag(cli, opts, "export FIGMA_ACCESS_TOKEN (or FIGMA_TOKEN), or keep it in the macOS keychain under FIGMA_ACCESS_TOKEN")
239
244
 
240
245
  file_key = opts.file
241
246
  nodes = [piece for piece in (opts.nodes or "").split(",") if piece.strip()]
@@ -246,10 +251,13 @@ def main():
246
251
 
247
252
  source, found, files, audited = pick_source(opts, cli, file_key, nodes)
248
253
 
249
- first_seen = {}
254
+ unique, seen = [], set()
250
255
  for item in found:
251
- first_seen.setdefault(item["nodeId"], item)
252
- unique = list(first_seen.values())
256
+ if item["nodeId"]:
257
+ if item["nodeId"] in seen:
258
+ continue
259
+ seen.add(item["nodeId"])
260
+ unique.append(item)
253
261
 
254
262
  def having(lang):
255
263
  return sum(1 for entry in unique if entry[lang])
@@ -11,13 +11,13 @@ import os
11
11
  import sys
12
12
  import urllib.request
13
13
 
14
- ALL_LANGS = ["en", "tr", "ar"] + sorted(["ru", "it", "fr", "es", "de"])
14
+ from _shared import parse_langs
15
15
 
16
16
 
17
- def parse_langs(value):
18
- if value.strip() == "all":
19
- return list(ALL_LANGS)
20
- return [part.strip() for part in value.split(",") if part.strip()]
17
+ def snapshot_langs(snapshot_dir):
18
+ if not os.path.isdir(snapshot_dir):
19
+ return []
20
+ return [name[:-5] for name in os.listdir(snapshot_dir) if name.endswith(".json")]
21
21
 
22
22
 
23
23
  def parse_pairs(pairs, flag):
@@ -80,7 +80,7 @@ def main():
80
80
  cli.add_argument("--endpoint", required=True, help="URL template containing {lang} and, if needed, {env}")
81
81
  cli.add_argument("--header", action="append", help="additional request header as K=V; may be repeated")
82
82
  cli.add_argument("--headers-file", help="request headers as a JSON object, so secrets stay out of argv")
83
- cli.add_argument("--langs", default="all", help="languages separated by commas, or 'all'")
83
+ cli.add_argument("--langs", default="all", help="languages separated by commas, or 'all' for every language already in the snapshot (en when it is empty)")
84
84
  cli.add_argument("--env", default="", help="text put in place of {env}")
85
85
  cli.add_argument("--status-field", default="statusCode", help="name of the response field that holds a status code")
86
86
  cli.add_argument("--fail-status", action="append", help="CODE=message pair that marks a failed response; may be repeated")
@@ -92,7 +92,7 @@ def main():
92
92
 
93
93
  headers = build_headers(opts)
94
94
  fail_status = parse_pairs(opts.fail_status, "--fail-status")
95
- langs = parse_langs(opts.langs)
95
+ langs = parse_langs(opts.langs, snapshot_langs(opts.snapshot_dir))
96
96
  os.makedirs(opts.snapshot_dir, exist_ok=True)
97
97
 
98
98
  total = 0