@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.
- package/CHANGELOG.md +48 -0
- package/docs/facts.json +3 -3
- package/index.js +1 -0
- package/install/_codex-agents.mjs +1 -1
- package/install/_common.mjs +486 -53
- package/install/_mcp-register.mjs +173 -117
- package/install/claude.mjs +281 -220
- package/install/codex.mjs +7 -7
- package/install/copilot.mjs +13 -11
- package/install/index.mjs +92 -27
- package/install/templates/claude-hooks.json +9 -9
- package/manifest.json +112 -114
- package/package.json +5 -2
- package/pipeline/commands/multi-agent/update/SKILL.md +28 -17
- package/pipeline/lib/confusables.json +79 -33
- package/pipeline/lib/extract-conventions.sh +3 -3
- package/pipeline/lib/json-file-lock.mjs +27 -7
- package/pipeline/lib/normalize-text.mjs +86 -17
- package/pipeline/lib/outbound-gate.mjs +13 -4
- package/pipeline/lib/redact.mjs +87 -13
- package/pipeline/multi-agent-refs/analysis/evidence.md +1 -1
- package/pipeline/multi-agent-refs/analysis/synthesis.md +1 -1
- package/pipeline/multi-agent-refs/component-dispatch.md +1 -1
- package/pipeline/multi-agent-refs/conventions-defaults.md +1 -1
- package/pipeline/multi-agent-refs/features/unattended-security.md +2 -2
- package/pipeline/scripts/agent-guard.py +150 -25
- package/pipeline/scripts/audit-log.sh +3 -4
- package/pipeline/scripts/autopilot-runner.mjs +14 -5
- package/pipeline/scripts/doctor.mjs +8 -2
- package/pipeline/scripts/gen-skills-index.mjs +13 -1
- package/pipeline/scripts/log-metric.sh +9 -3
- package/pipeline/scripts/migrate-prefs.mjs +18 -4
- package/pipeline/scripts/pre-commit-check.sh +119 -27
- package/pipeline/scripts/scan-agent-config.sh +9 -9
- package/pipeline/scripts/unattended_policy.py +12 -3
- package/pipeline/scripts/uninstall.mjs +88 -1
- package/pipeline/scripts/usage-identity.mjs +1 -1
- package/pipeline/scripts/usage-register.mjs +1 -1
- package/pipeline/skills/.skill-manifest.json +30 -30
- package/pipeline/skills/shared/README.md +64 -64
- package/pipeline/skills/shared/external/alarmkit/SKILL.md +2 -2
- package/pipeline/skills/shared/external/alarmkit/evals/evals.json +2 -2
- package/pipeline/skills/shared/external/app-store-optimization/SKILL.md +6 -0
- package/pipeline/skills/shared/external/app-store-optimization/references/keyword-research-methodology.md +3 -0
- package/pipeline/skills/shared/external/app-store-optimization/references/product-page-variants.md +3 -0
- package/pipeline/skills/shared/external/app-store-review/SKILL.md +3 -4
- package/pipeline/skills/shared/external/apple-on-device-ai/SKILL.md +5 -3
- package/pipeline/skills/shared/external/authentication/SKILL.md +29 -17
- package/pipeline/skills/shared/external/authentication/references/keychain-biometric.md +3 -1
- package/pipeline/skills/shared/external/background-processing/SKILL.md +10 -8
- package/pipeline/skills/shared/external/background-processing/references/background-task-patterns.md +7 -7
- package/pipeline/skills/shared/external/callkit-voip/SKILL.md +6 -3
- package/pipeline/skills/shared/external/cloudkit-sync/SKILL.md +43 -0
- package/pipeline/skills/shared/external/core-bluetooth/SKILL.md +4 -2
- package/pipeline/skills/shared/external/core-data/SKILL.md +12 -2
- package/pipeline/skills/shared/external/core-nfc/SKILL.md +31 -0
- package/pipeline/skills/shared/external/coreml/SKILL.md +1 -1
- package/pipeline/skills/shared/external/cryptokit/SKILL.md +1 -1
- package/pipeline/skills/shared/external/device-integrity/SKILL.md +11 -5
- package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +2 -2
- package/pipeline/skills/shared/external/ios-module-structure/modules/_TEMPLATE.yml +1 -1
- package/pipeline/skills/shared/external/localization-reuse-map/SKILL.md +6 -6
- package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +3 -2
- package/pipeline/skills/shared/external/localization-reuse-map/reference/format-and-output.md +20 -18
- package/pipeline/skills/shared/external/localization-reuse-map/reference/publish-and-snapshot.md +4 -4
- package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +12 -9
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/_shared.py +201 -0
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-artifact.py +64 -30
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/build-spreadsheet.py +14 -16
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-annotations.py +20 -12
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/fetch-legacy-labels.py +7 -7
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py +26 -12
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-key-shots.py +4 -3
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/render-overlay.py +51 -24
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py +19 -9
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py +26 -19
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/scan-screen-keys.py +39 -46
- package/pipeline/skills/shared/external/localization-reuse-map/scripts/verify-map.py +37 -63
- package/pipeline/skills/shared/external/mapkit-location/SKILL.md +4 -2
- package/pipeline/skills/shared/external/passkit-wallet/SKILL.md +5 -4
- package/pipeline/skills/shared/external/passkit-wallet/references/wallet-passes.md +3 -2
- package/pipeline/skills/shared/external/pencilkit-drawing/SKILL.md +2 -2
- package/pipeline/skills/shared/external/pencilkit-drawing/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/pencilkit-drawing/references/pencilkit-patterns.md +4 -4
- package/pipeline/skills/shared/external/permissionkit/SKILL.md +15 -6
- package/pipeline/skills/shared/external/permissionkit/references/permissionkit-patterns.md +2 -1
- package/pipeline/skills/shared/external/push-notifications/SKILL.md +8 -4
- package/pipeline/skills/shared/external/push-notifications/references/notification-patterns.md +1 -1
- package/pipeline/skills/shared/external/realitykit-ar/SKILL.md +25 -6
- package/pipeline/skills/shared/external/realitykit-ar/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/skill-creator/template.md +7 -1
- package/pipeline/skills/shared/external/storekit/references/core-patterns.md +6 -1
- package/pipeline/skills/shared/external/swift-concurrency/SKILL.md +3 -2
- package/pipeline/skills/shared/external/swift-concurrency/references/concurrency-patterns.md +1 -1
- package/pipeline/skills/shared/external/swift-security/SKILL.md +10 -8
- package/pipeline/skills/shared/external/swift-security/references/certificate-trust.md +8 -5
- package/pipeline/skills/shared/external/swift-security/references/keychain-fundamentals.md +13 -9
- package/pipeline/skills/shared/external/swift-security/references/secure-enclave.md +7 -6
- package/pipeline/skills/shared/external/swift-testing/SKILL.md +8 -5
- package/pipeline/skills/shared/external/swift-testing/evals/evals.json +1 -1
- package/pipeline/skills/shared/external/swift-testing/references/testing-advanced.md +3 -2
- package/pipeline/skills/shared/external/swiftdata/SKILL.md +5 -3
- package/pipeline/skills/shared/external/swiftui-navigation/SKILL.md +17 -9
- package/pipeline/skills/shared/external/swiftui-navigation/references/navigationstack.md +4 -2
- package/pipeline/skills/shared/external/swiftui-navigation/references/tabview.md +13 -6
- package/pipeline/skills/shared/external/vision-framework/SKILL.md +3 -1
- package/pipeline/skills/shared/external/weatherkit/SKILL.md +8 -5
- package/pipeline/skills/shared/external/widgetkit/SKILL.md +15 -7
- package/pipeline/skills/shared/external/widgetkit/references/widgetkit-advanced.md +8 -6
- package/pipeline/scripts/gen-ref-toc.mjs +0 -279
- package/pipeline/scripts/make-manifest.mjs +0 -199
- package/pipeline/scripts/scorecard-snapshot.mjs +0 -178
package/pipeline/skills/shared/external/localization-reuse-map/scripts/publish-confluence.py
CHANGED
|
@@ -16,11 +16,16 @@ import subprocess
|
|
|
16
16
|
import sys
|
|
17
17
|
import tempfile
|
|
18
18
|
import urllib.parse
|
|
19
|
-
from xml.sax.saxutils import quoteattr
|
|
19
|
+
from xml.sax.saxutils import escape, quoteattr
|
|
20
|
+
|
|
21
|
+
from _shared import add_token_flag, reject_token_flag
|
|
20
22
|
|
|
21
23
|
EMBED_EXTENSIONS = {".xlsx", ".xls", ".csv", ".docx", ".pdf"}
|
|
22
|
-
EMBED_HEADING = "CMS
|
|
24
|
+
EMBED_HEADING = "CMS Import File"
|
|
23
25
|
CONTENT = "/rest/api/content"
|
|
26
|
+
CONNECT_TIMEOUT = 15
|
|
27
|
+
REQUEST_TIMEOUT = 120
|
|
28
|
+
PROCESS_TIMEOUT = 150
|
|
24
29
|
|
|
25
30
|
|
|
26
31
|
class Client:
|
|
@@ -39,7 +44,10 @@ class Client:
|
|
|
39
44
|
os.rmdir(self.tmp)
|
|
40
45
|
|
|
41
46
|
def call(self, method, path, body=None, form_file=None):
|
|
42
|
-
argv = [
|
|
47
|
+
argv = [
|
|
48
|
+
"curl", "-sS", "--connect-timeout", str(CONNECT_TIMEOUT), "--max-time", str(REQUEST_TIMEOUT),
|
|
49
|
+
"-K", self.config, "-X", method,
|
|
50
|
+
]
|
|
43
51
|
if body is not None:
|
|
44
52
|
payload = os.path.join(self.tmp, "body.json")
|
|
45
53
|
with open(payload, "w", encoding="utf-8") as sink:
|
|
@@ -48,7 +56,10 @@ class Client:
|
|
|
48
56
|
if form_file is not None:
|
|
49
57
|
argv += ["-F", "file=@" + form_file, "-H", "X-Atlassian-Token: nocheck"]
|
|
50
58
|
argv.append(self.base + path)
|
|
51
|
-
|
|
59
|
+
try:
|
|
60
|
+
done = subprocess.run(argv, text=True, capture_output=True, timeout=PROCESS_TIMEOUT)
|
|
61
|
+
except subprocess.TimeoutExpired:
|
|
62
|
+
sys.exit(f"curl did not finish within {PROCESS_TIMEOUT}s: {method} {path}")
|
|
52
63
|
if done.returncode != 0:
|
|
53
64
|
sys.exit(f"curl did not complete: {done.stderr.strip()}")
|
|
54
65
|
try:
|
|
@@ -60,9 +71,7 @@ class Client:
|
|
|
60
71
|
return reply
|
|
61
72
|
|
|
62
73
|
|
|
63
|
-
def resolve_token(
|
|
64
|
-
if explicit:
|
|
65
|
-
return explicit
|
|
74
|
+
def resolve_token(keychain_name):
|
|
66
75
|
if os.environ.get("CONFLUENCE_API_TOKEN"):
|
|
67
76
|
return os.environ["CONFLUENCE_API_TOKEN"]
|
|
68
77
|
try:
|
|
@@ -82,8 +91,8 @@ def view_file_macro(path):
|
|
|
82
91
|
return f'<p><ac:structured-macro ac:name="view-file">{parameter}</ac:structured-macro></p>'
|
|
83
92
|
|
|
84
93
|
|
|
85
|
-
def embed_block(files):
|
|
86
|
-
return f"<h2>{
|
|
94
|
+
def embed_block(files, heading):
|
|
95
|
+
return f"<h2>{escape(heading)}</h2>" + "".join(map(view_file_macro, files))
|
|
87
96
|
|
|
88
97
|
|
|
89
98
|
def keyshot_files(manifest_path):
|
|
@@ -111,8 +120,9 @@ def build_cli():
|
|
|
111
120
|
cli.add_argument("--base", "--base-url", dest="base", default=os.environ.get("CONFLUENCE_BASE_URL"))
|
|
112
121
|
cli.add_argument("--space", default=os.environ.get("CONFLUENCE_SPACE"))
|
|
113
122
|
cli.add_argument("--parent", default=os.environ.get("CONFLUENCE_PARENT"), help="id of the parent page")
|
|
114
|
-
cli
|
|
123
|
+
add_token_flag(cli)
|
|
115
124
|
cli.add_argument("--keychain-name", default="CONFLUENCE_API_TOKEN", help="keychain service that stores the PAT")
|
|
125
|
+
cli.add_argument("--embed-heading", default=EMBED_HEADING, help="heading above the embedded spreadsheet / document previews")
|
|
116
126
|
cli.add_argument("--dry-run", action="store_true", help="report the planned actions without touching Confluence")
|
|
117
127
|
return cli
|
|
118
128
|
|
|
@@ -162,6 +172,10 @@ def upload(client, page_id, path):
|
|
|
162
172
|
def main():
|
|
163
173
|
cli = build_cli()
|
|
164
174
|
opts = cli.parse_args()
|
|
175
|
+
reject_token_flag(
|
|
176
|
+
cli, opts,
|
|
177
|
+
f"export CONFLUENCE_API_TOKEN, or keep it in the macOS keychain under the --keychain-name service ({opts.keychain_name})",
|
|
178
|
+
)
|
|
165
179
|
check_inputs(cli, opts)
|
|
166
180
|
title = opts.title or f"{opts.title_prefix} - {opts.screen}"
|
|
167
181
|
|
|
@@ -174,7 +188,7 @@ def main():
|
|
|
174
188
|
body = stream.read()
|
|
175
189
|
embedded = [p for p in opts.attach if os.path.splitext(p)[1].lower() in EMBED_EXTENSIONS]
|
|
176
190
|
if embedded:
|
|
177
|
-
body += embed_block(embedded)
|
|
191
|
+
body += embed_block(embedded, opts.embed_heading)
|
|
178
192
|
|
|
179
193
|
if opts.dry_run:
|
|
180
194
|
plan = {
|
|
@@ -184,7 +198,7 @@ def main():
|
|
|
184
198
|
print(json.dumps(plan, indent=2, ensure_ascii=False))
|
|
185
199
|
return
|
|
186
200
|
|
|
187
|
-
token = resolve_token(opts.
|
|
201
|
+
token = resolve_token(opts.keychain_name)
|
|
188
202
|
if not token:
|
|
189
203
|
sys.exit(
|
|
190
204
|
"No Confluence token available. Put a Server/DC personal access token in the keychain "
|
|
@@ -145,11 +145,12 @@ def main():
|
|
|
145
145
|
cli.add_argument("--nodes", help="frame node-ids separated by commas; falls back to figmaNodes")
|
|
146
146
|
cli.add_argument("--pad", default=150, type=int, help="pixels of surrounding context kept over and under the element")
|
|
147
147
|
cli.add_argument("--scale", default=2, type=int)
|
|
148
|
-
|
|
148
|
+
ov.add_token_flag(cli)
|
|
149
149
|
cli.add_argument("--ca-file", help="PEM roots to trust when a proxy intercepts TLS")
|
|
150
150
|
cli.add_argument("--out", dest="target_dir", default=".", help="folder for the manifest; the crops go into <out>/keyshots/")
|
|
151
151
|
cli.add_argument("--slug", help="stem of the manifest name; derived from the screen name when omitted")
|
|
152
152
|
opts = cli.parse_args()
|
|
153
|
+
ov.reject_token_flag(cli, opts, ov.FIGMA_TOKEN_HINT)
|
|
153
154
|
|
|
154
155
|
with open(opts.mapping, encoding="utf-8") as stream:
|
|
155
156
|
mapping = json.load(stream)
|
|
@@ -163,9 +164,9 @@ def main():
|
|
|
163
164
|
nodes = ov.split_nodes(opts.nodes, mapping)
|
|
164
165
|
if not (file_key and nodes):
|
|
165
166
|
cli.error("need a Figma file key and frame node-ids (flags or mapping), or --spec")
|
|
166
|
-
token = ov.figma_token(
|
|
167
|
+
token = ov.figma_token()
|
|
167
168
|
if not token:
|
|
168
|
-
sys.exit("No Figma token found (FIGMA_ACCESS_TOKEN, FIGMA_TOKEN
|
|
169
|
+
sys.exit("No Figma token found (FIGMA_ACCESS_TOKEN, FIGMA_TOKEN or keychain FIGMA_ACCESS_TOKEN). "
|
|
169
170
|
"Without one, build --spec files from the screenshots instead.")
|
|
170
171
|
pages = rest_pages(file_key, nodes, opts.scale, token, opts.ca_file)
|
|
171
172
|
|
|
@@ -25,6 +25,10 @@ import urllib.error
|
|
|
25
25
|
import urllib.parse
|
|
26
26
|
import urllib.request
|
|
27
27
|
|
|
28
|
+
from _shared import add_token_flag, reject_token_flag, slug
|
|
29
|
+
|
|
30
|
+
FIGMA_TOKEN_HINT = "export FIGMA_ACCESS_TOKEN (or FIGMA_TOKEN), or keep it in the macOS keychain under FIGMA_ACCESS_TOKEN"
|
|
31
|
+
|
|
28
32
|
API = "https://api.figma.com/v1"
|
|
29
33
|
SEP = "[:\\-\u2013]"
|
|
30
34
|
TR_RE = re.compile("^(?:tr|tr[kc]e|t[\u00fcu]rk[\u00e7c]e)\\s*" + SEP + "\\s*(.+)$", re.IGNORECASE)
|
|
@@ -40,9 +44,10 @@ RIGHT_MARGIN = 40
|
|
|
40
44
|
PINK = "#EC1E91"
|
|
41
45
|
GRAY = "#9aa0a6"
|
|
42
46
|
BACKGROUND = "#1e1e1e"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
47
|
+
LABELS = {
|
|
48
|
+
"en": {"cms": "CMS copy", "pending": "Awaiting copy", "unmatched": "(unmatched)"},
|
|
49
|
+
"tr": {"cms": "\u0130\u00e7erik (CMS)", "pending": "\u00d6neri bekleniyor", "unmatched": "(e\u015fle\u015fmedi)"},
|
|
50
|
+
}
|
|
46
51
|
|
|
47
52
|
PAGE_OPEN = "<!doctype html><html><head>"
|
|
48
53
|
META_UTF8 = '<meta charset="utf-8">'
|
|
@@ -60,13 +65,12 @@ PATH_CHROMES = (
|
|
|
60
65
|
)
|
|
61
66
|
|
|
62
67
|
|
|
63
|
-
|
|
64
|
-
return str(node_id or "").strip().replace("-", ":")
|
|
68
|
+
URL_NODE_ID = re.compile(r"^\d+-\d+$")
|
|
65
69
|
|
|
66
70
|
|
|
67
|
-
def
|
|
68
|
-
|
|
69
|
-
return
|
|
71
|
+
def canon_id(node_id):
|
|
72
|
+
text = str(node_id or "").strip()
|
|
73
|
+
return text.replace("-", ":") if URL_NODE_ID.match(text) else text
|
|
70
74
|
|
|
71
75
|
|
|
72
76
|
def split_lang(text):
|
|
@@ -135,9 +139,7 @@ def keychain_secret(service):
|
|
|
135
139
|
return secret if lookup.returncode == 0 and secret else None
|
|
136
140
|
|
|
137
141
|
|
|
138
|
-
def figma_token(
|
|
139
|
-
if explicit:
|
|
140
|
-
return explicit
|
|
142
|
+
def figma_token():
|
|
141
143
|
from_env = next((os.environ[v] for v in ("FIGMA_ACCESS_TOKEN", "FIGMA_TOKEN") if os.environ.get(v)), None)
|
|
142
144
|
return from_env or keychain_secret("FIGMA_ACCESS_TOKEN")
|
|
143
145
|
|
|
@@ -248,7 +250,7 @@ def page_from_rest(file_key, node_id, scale, token, ca_file):
|
|
|
248
250
|
}
|
|
249
251
|
|
|
250
252
|
|
|
251
|
-
def build_cards(page, rows):
|
|
253
|
+
def build_cards(page, rows, labels):
|
|
252
254
|
index = build_index(rows)
|
|
253
255
|
cards = []
|
|
254
256
|
taken = set()
|
|
@@ -273,7 +275,7 @@ def build_cards(page, rows):
|
|
|
273
275
|
box = {axis: float(node.get(axis, 0)) for axis in ("x", "y", "w", "h")}
|
|
274
276
|
cards.append({
|
|
275
277
|
"num": number,
|
|
276
|
-
"key": new_key or
|
|
278
|
+
"key": new_key or labels["unmatched"],
|
|
277
279
|
"tr": tr,
|
|
278
280
|
"en": en,
|
|
279
281
|
"text": node.get("characters") or "",
|
|
@@ -302,9 +304,9 @@ def chip(kind, colour, content):
|
|
|
302
304
|
return f'<span class="{kind}" style="background:{colour}">{content}</span>'
|
|
303
305
|
|
|
304
306
|
|
|
305
|
-
def card_html(card, left):
|
|
307
|
+
def card_html(card, left, labels):
|
|
306
308
|
colour = PINK if card["annotated"] else GRAY
|
|
307
|
-
pill =
|
|
309
|
+
pill = labels["cms"] if card["annotated"] else labels["pending"]
|
|
308
310
|
if card["annotated"]:
|
|
309
311
|
body = "".join(
|
|
310
312
|
f'<div class="line"><b>{label}</b> {html.escape(card[lang] or "-")}</div>'
|
|
@@ -336,7 +338,7 @@ def connector(card, lane, lane_count, start_x, target_x, from_left):
|
|
|
336
338
|
)
|
|
337
339
|
|
|
338
340
|
|
|
339
|
-
def build_html(page, cards):
|
|
341
|
+
def build_html(page, cards, labels):
|
|
340
342
|
fw, fh = page["frame"]["w"], page["frame"]["h"]
|
|
341
343
|
sides = {"left": [], "right": []}
|
|
342
344
|
for card in cards:
|
|
@@ -350,7 +352,7 @@ def build_html(page, cards):
|
|
|
350
352
|
for card_x, group in ((COLUMN_X, sides["left"]), (right_x, sides["right"])):
|
|
351
353
|
from_left = card_x == COLUMN_X
|
|
352
354
|
for lane, card in enumerate(group):
|
|
353
|
-
boxes.append(card_html(card, card_x))
|
|
355
|
+
boxes.append(card_html(card, card_x, labels))
|
|
354
356
|
if from_left:
|
|
355
357
|
wire = connector(card, lane, len(group), COLUMN_X + CARD_W, screen_x + card["x"], True)
|
|
356
358
|
else:
|
|
@@ -430,12 +432,26 @@ def chrome_screenshot(chrome, html_text, out_png, width, height, scale):
|
|
|
430
432
|
raise RuntimeError((log.read() or "no output").strip()[-300:])
|
|
431
433
|
|
|
432
434
|
|
|
435
|
+
def is_number(value):
|
|
436
|
+
try:
|
|
437
|
+
float(value)
|
|
438
|
+
except (TypeError, ValueError):
|
|
439
|
+
return False
|
|
440
|
+
return True
|
|
441
|
+
|
|
442
|
+
|
|
433
443
|
def load_spec_pages(path):
|
|
434
444
|
with open(path, encoding="utf-8") as stream:
|
|
435
445
|
spec = json.load(stream)
|
|
436
446
|
folder = os.path.dirname(os.path.abspath(path))
|
|
437
447
|
pages = spec.get("pages") or []
|
|
438
|
-
for page in pages:
|
|
448
|
+
for number, page in enumerate(pages, 1):
|
|
449
|
+
frame = page.get("frame") if isinstance(page, dict) else None
|
|
450
|
+
if not (isinstance(frame, dict) and all(is_number(frame.get(side)) for side in ("w", "h"))):
|
|
451
|
+
sys.exit(f"{path}: page {number} needs \"frame\": {{\"w\": <width>, \"h\": <height>}} in px")
|
|
452
|
+
for side in ("w", "h"):
|
|
453
|
+
if not isinstance(frame[side], (int, float)):
|
|
454
|
+
frame[side] = float(frame[side])
|
|
439
455
|
page["image"] = image_source(page.get("image"), folder)
|
|
440
456
|
return spec, pages
|
|
441
457
|
|
|
@@ -445,18 +461,26 @@ def split_nodes(flag, mapping):
|
|
|
445
461
|
return given or list(mapping.get("figmaNodes") or [])
|
|
446
462
|
|
|
447
463
|
|
|
448
|
-
def
|
|
464
|
+
def build_cli():
|
|
449
465
|
cli = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
450
466
|
cli.add_argument("--mapping", required=True, help="the reuse-map JSON")
|
|
451
467
|
cli.add_argument("--spec", help="geometry from MCP or measured by hand: {fileKey, pages:[{nodeId, image, frame, nodes}]}")
|
|
452
468
|
cli.add_argument("--file", help="Figma file key; falls back to figmaFileKey in the mapping")
|
|
453
469
|
cli.add_argument("--nodes", help="node-ids separated by commas, the screen frame first; falls back to figmaNodes")
|
|
454
470
|
cli.add_argument("--scale", default=2, type=int)
|
|
455
|
-
cli
|
|
471
|
+
add_token_flag(cli)
|
|
456
472
|
cli.add_argument("--ca-file", help="PEM roots to trust when a proxy intercepts TLS")
|
|
457
473
|
cli.add_argument("--out", dest="target_dir", default=".", help="folder that receives the generated files")
|
|
458
474
|
cli.add_argument("--slug", help="stem for the file names; derived from the screen name when omitted")
|
|
475
|
+
cli.add_argument("--ui-lang", choices=sorted(LABELS), default="en", help="language of the card labels")
|
|
476
|
+
return cli
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def main():
|
|
480
|
+
cli = build_cli()
|
|
459
481
|
opts = cli.parse_args()
|
|
482
|
+
reject_token_flag(cli, opts, FIGMA_TOKEN_HINT)
|
|
483
|
+
labels = LABELS[opts.ui_lang]
|
|
460
484
|
|
|
461
485
|
with open(opts.mapping, encoding="utf-8") as stream:
|
|
462
486
|
mapping = json.load(stream)
|
|
@@ -467,20 +491,23 @@ def main():
|
|
|
467
491
|
if not pages:
|
|
468
492
|
sys.exit("--spec lists no pages")
|
|
469
493
|
page = pages[0]
|
|
494
|
+
if len(pages) > 1:
|
|
495
|
+
print(f"# warning: {len(pages) - 1} extra page(s) in --spec ignored; the overlay draws the first page only "
|
|
496
|
+
"(render-key-shots.py reads every page)", file=sys.stderr)
|
|
470
497
|
file_key = spec.get("fileKey") or mapping.get("figmaFileKey")
|
|
471
498
|
else:
|
|
472
499
|
file_key = opts.file or mapping.get("figmaFileKey")
|
|
473
500
|
nodes = split_nodes(opts.nodes, mapping)
|
|
474
501
|
if not (file_key and nodes):
|
|
475
502
|
cli.error("need a Figma file key and frame node-id (flags or mapping), or --spec")
|
|
476
|
-
token = figma_token(
|
|
503
|
+
token = figma_token()
|
|
477
504
|
if not token:
|
|
478
|
-
sys.exit("No Figma token found (FIGMA_ACCESS_TOKEN, FIGMA_TOKEN
|
|
505
|
+
sys.exit("No Figma token found (FIGMA_ACCESS_TOKEN, FIGMA_TOKEN or keychain FIGMA_ACCESS_TOKEN). "
|
|
479
506
|
"Without one, measure the boxes on the screenshot and hand them over with --spec.")
|
|
480
507
|
page = page_from_rest(file_key, canon_id(nodes[0]), opts.scale, token, opts.ca_file)
|
|
481
508
|
|
|
482
|
-
cards = build_cards(page, mapping.get("rows") or [])
|
|
483
|
-
document, width, height = build_html(page, cards)
|
|
509
|
+
cards = build_cards(page, mapping.get("rows") or [], labels)
|
|
510
|
+
document, width, height = build_html(page, cards, labels)
|
|
484
511
|
os.makedirs(opts.target_dir, exist_ok=True)
|
|
485
512
|
|
|
486
513
|
def output(suffix):
|
package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-legacy-values.py
CHANGED
|
@@ -13,17 +13,25 @@ import plistlib
|
|
|
13
13
|
import sys
|
|
14
14
|
import urllib.request
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
from _shared import parse_langs
|
|
17
|
+
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
def split_csv(value):
|
|
20
21
|
return [part.strip() for part in value.split(",") if part.strip()]
|
|
21
22
|
|
|
22
23
|
|
|
23
|
-
def
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
def present_langs(snapshot_root, plist_root):
|
|
25
|
+
found = []
|
|
26
|
+
if snapshot_root and os.path.isdir(snapshot_root):
|
|
27
|
+
found += [name[:-5] for name in os.listdir(snapshot_root) if name.endswith(".json")]
|
|
28
|
+
if plist_root and os.path.isdir(plist_root):
|
|
29
|
+
for name in os.listdir(plist_root):
|
|
30
|
+
if name.endswith(".lproj") and os.path.isfile(os.path.join(plist_root, name, "language.plist")):
|
|
31
|
+
found.append(name[: -len(".lproj")])
|
|
32
|
+
elif name.endswith(".plist") and name != "language.plist":
|
|
33
|
+
found.append(name[: -len(".plist")])
|
|
34
|
+
return found
|
|
27
35
|
|
|
28
36
|
|
|
29
37
|
def parse_pairs(pairs, flag):
|
|
@@ -133,7 +141,7 @@ def from_live(args, keys, langs):
|
|
|
133
141
|
def main():
|
|
134
142
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
135
143
|
parser.add_argument("--keys", required=True, help="legacy keys separated by commas; leave off the backend prefix")
|
|
136
|
-
parser.add_argument("--langs", default="
|
|
144
|
+
parser.add_argument("--langs", default="all", help="comma list, or 'all' for every language the snapshot or plist root holds")
|
|
137
145
|
parser.add_argument("--snapshot-root", help="directory holding <lang>.json flat snapshots")
|
|
138
146
|
parser.add_argument("--live", action="store_true", help="query the label endpoint instead of the snapshot")
|
|
139
147
|
parser.add_argument("--endpoint", help="URL template with {lang} and optional {env}")
|
|
@@ -144,11 +152,13 @@ def main():
|
|
|
144
152
|
parser.add_argument("--fail-status", action="append", help="CODE=message treated as a failure (repeatable)")
|
|
145
153
|
parser.add_argument("--timeout", type=int, default=30)
|
|
146
154
|
parser.add_argument("--plist-root", help="last resort: directory of bundled <lang>.lproj/language.plist")
|
|
147
|
-
parser.add_argument("--prefix", default="", help="backend key prefix the app strips, e.g.
|
|
155
|
+
parser.add_argument("--prefix", default="", help="backend key prefix the app strips, e.g. App-")
|
|
148
156
|
args = parser.parse_args()
|
|
149
157
|
|
|
150
158
|
keys = split_csv(args.keys)
|
|
151
|
-
langs = parse_langs(args.langs)
|
|
159
|
+
langs = parse_langs(args.langs, present_langs(args.snapshot_root, args.plist_root))
|
|
160
|
+
if not (args.snapshot_root or args.plist_root or args.endpoint):
|
|
161
|
+
sys.exit("no source given: pass --snapshot-root, --plist-root, or --live with --endpoint")
|
|
152
162
|
|
|
153
163
|
if args.snapshot_root and not args.live:
|
|
154
164
|
result = from_snapshot(args.snapshot_root, keys, langs, args.prefix)
|
|
@@ -165,7 +175,7 @@ def main():
|
|
|
165
175
|
elif args.plist_root:
|
|
166
176
|
result = from_plist(args.plist_root, keys, langs, args.prefix)
|
|
167
177
|
else:
|
|
168
|
-
|
|
178
|
+
sys.exit("no --snapshot-root or --plist-root to fall back on")
|
|
169
179
|
|
|
170
180
|
json.dump(result, sys.stdout, indent=2, ensure_ascii=False)
|
|
171
181
|
sys.stdout.write("\n")
|
package/pipeline/skills/shared/external/localization-reuse-map/scripts/resolve-new-values.py
CHANGED
|
@@ -11,7 +11,8 @@ import json
|
|
|
11
11
|
import os
|
|
12
12
|
import sys
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
from _shared import normalize_key, parse_langs
|
|
15
|
+
|
|
15
16
|
|
|
16
17
|
DOMAIN_DIRS = {
|
|
17
18
|
"localization": ("Suggested", "LocalizationStrings"),
|
|
@@ -19,16 +20,6 @@ DOMAIN_DIRS = {
|
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
|
|
22
|
-
def parse_langs(value):
|
|
23
|
-
if value.strip() == "all":
|
|
24
|
-
return list(ALL_LANGS)
|
|
25
|
-
return [part.strip() for part in value.split(",") if part.strip()]
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def normalize_key(key):
|
|
29
|
-
return ".".join(seg[:1].upper() + seg[1:] for seg in key.split("."))
|
|
30
|
-
|
|
31
|
-
|
|
32
23
|
def read_keys(opts):
|
|
33
24
|
raw = [piece.strip() for piece in opts.keys.split(",")] if opts.keys else []
|
|
34
25
|
if opts.keys_file:
|
|
@@ -84,13 +75,29 @@ def read_suggested(path, langs):
|
|
|
84
75
|
return {lang: value_of(lang) for lang in langs}
|
|
85
76
|
|
|
86
77
|
|
|
78
|
+
def present_langs(directory, catalog, keys):
|
|
79
|
+
found = []
|
|
80
|
+
for key in keys:
|
|
81
|
+
path = os.path.join(directory, normalize_key(key) + ".json")
|
|
82
|
+
if os.path.isfile(path):
|
|
83
|
+
data = read_json(path)
|
|
84
|
+
found.extend(data.keys() if isinstance(data, dict) else [])
|
|
85
|
+
for candidate in dict.fromkeys((key, normalize_key(key))):
|
|
86
|
+
entry = (catalog or {}).get(candidate)
|
|
87
|
+
if isinstance(entry, dict):
|
|
88
|
+
found.extend(entry.keys())
|
|
89
|
+
return found
|
|
90
|
+
|
|
91
|
+
|
|
87
92
|
def from_catalog(catalog, key, langs):
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
93
|
+
for candidate in dict.fromkeys((key, normalize_key(key))):
|
|
94
|
+
entry = catalog.get(candidate)
|
|
95
|
+
if not isinstance(entry, dict):
|
|
96
|
+
continue
|
|
97
|
+
values = {lang: entry.get(lang) for lang in langs}
|
|
98
|
+
if any(value is not None for value in values.values()):
|
|
99
|
+
return values
|
|
100
|
+
return None
|
|
94
101
|
|
|
95
102
|
|
|
96
103
|
def main():
|
|
@@ -102,7 +109,7 @@ def main():
|
|
|
102
109
|
cli.add_argument("--domain", default="localization", choices=sorted(DOMAIN_DIRS))
|
|
103
110
|
cli.add_argument("--catalog", help="string catalog the app ships (.xcstrings or flat JSON), consulted when a key has no Suggested file")
|
|
104
111
|
cli.add_argument("--report-source", action="store_true", help="return {source, values} per key instead of the bare values")
|
|
105
|
-
cli.add_argument("--langs", default="
|
|
112
|
+
cli.add_argument("--langs", default="all", help="languages separated by commas, or 'all' for every language the sources hold")
|
|
106
113
|
opts = cli.parse_args()
|
|
107
114
|
|
|
108
115
|
keys = read_keys(opts)
|
|
@@ -114,8 +121,8 @@ def main():
|
|
|
114
121
|
if not os.path.isdir(directory):
|
|
115
122
|
cli.error(f"no Suggested folder at {directory} (check --resources-root / --suggested-dir / --domain)")
|
|
116
123
|
|
|
117
|
-
langs = parse_langs(opts.langs)
|
|
118
124
|
catalog = load_catalog(opts.catalog) if opts.catalog else None
|
|
125
|
+
langs = parse_langs(opts.langs, present_langs(directory, catalog, keys) if opts.langs.strip() == "all" else ())
|
|
119
126
|
|
|
120
127
|
result, via_catalog, unresolved = {}, [], []
|
|
121
128
|
for key in keys:
|
|
@@ -13,38 +13,28 @@ import os
|
|
|
13
13
|
import re
|
|
14
14
|
import sys
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
from _shared import (
|
|
17
|
+
ACCESSOR_EXTENSIONS, DEFAULT_KEY_TYPES, IDENT, WEB_DOLLAR_T, WEB_T, WORD, is_interpolated, key_type_alternation,
|
|
18
|
+
key_type_warning, key_types, lsk_keys, source_files,
|
|
19
|
+
)
|
|
20
|
+
|
|
17
21
|
ANDROID = {".kt", ".kts", ".xml"}
|
|
18
22
|
WEB = {".ts", ".tsx", ".js", ".jsx", ".vue"}
|
|
19
|
-
SKIP_PARTS = ("/.git", "/Generated/")
|
|
20
23
|
|
|
21
24
|
ERROR_WORDS = ("toast", "snackbar", "alert", "warning", "failure", "exception", "showerror", "errormessage", "error", "validat")
|
|
22
25
|
ERROR_CONTEXT = re.compile("|".join(ERROR_WORDS), re.IGNORECASE)
|
|
23
26
|
|
|
24
|
-
WORD = "A-Za-z0-9_"
|
|
25
|
-
IDENT = rf"[A-Za-z_][{WORD}]*"
|
|
26
|
-
LSK = re.compile(rf"LocalizationStringKey((?:\.{IDENT}){{2,}})")
|
|
27
27
|
LITERAL_LOCALIZED = re.compile(r"\"([^\"\\\n]+)\"\.localized\b")
|
|
28
28
|
R_STRING = re.compile(rf"\bR\.string\.([{WORD}]+)")
|
|
29
29
|
XML_STRING = re.compile(rf"@string/([{WORD}.]+)")
|
|
30
|
-
WEB_T = re.compile(r"(?<![\w$])t\(\s*(['\"])([A-Za-z0-9_.\-]+)\1")
|
|
31
|
-
WEB_DOLLAR_T = re.compile(r"\$t\(\s*(['\"])([A-Za-z0-9_.\-]+)\1")
|
|
32
30
|
WEB_T_DYNAMIC = re.compile(r"(?<![\w$])t\(\s*[`$A-Za-z_]")
|
|
33
31
|
WEB_DOLLAR_T_DYNAMIC = re.compile(r"\$t\(\s*[`$A-Za-z_]")
|
|
34
|
-
LSK_DYNAMIC = re.compile(r"LocalizationStringKey\(")
|
|
35
32
|
FORMAT_DYNAMIC = re.compile(r"String\(format:")
|
|
36
33
|
GETSTRING_DYNAMIC = re.compile(r"\bgetString\(\s*[A-Za-z_]")
|
|
37
34
|
INTERPOLATED_LOCALIZED = re.compile(r"\"([^\"\n]*(?:\\\(|\$\{|\$[A-Za-z_])[^\"\n]*)\"\.localized\b")
|
|
38
35
|
RECEIVER_LOCALIZED = re.compile(rf"(?<![\w.\"])({IDENT}(?:\.{IDENT})*)\.localized\b")
|
|
39
36
|
|
|
40
37
|
|
|
41
|
-
def strip_accessor(chain):
|
|
42
|
-
segments = chain.strip(".").split(".")
|
|
43
|
-
while segments and segments[-1].startswith("localized"):
|
|
44
|
-
segments.pop()
|
|
45
|
-
return ".".join(segments)
|
|
46
|
-
|
|
47
|
-
|
|
48
38
|
def platform_of(path):
|
|
49
39
|
ext = os.path.splitext(path)[1]
|
|
50
40
|
if ext in ANDROID:
|
|
@@ -54,21 +44,11 @@ def platform_of(path):
|
|
|
54
44
|
return "ios"
|
|
55
45
|
|
|
56
46
|
|
|
57
|
-
def
|
|
58
|
-
|
|
59
|
-
yield root
|
|
60
|
-
return
|
|
61
|
-
for current, dirs, files in os.walk(root):
|
|
62
|
-
probe = current.replace(os.sep, "/") + "/"
|
|
63
|
-
if any(part in probe for part in SKIP_PARTS):
|
|
64
|
-
dirs[:] = []
|
|
65
|
-
continue
|
|
66
|
-
for name in sorted(files):
|
|
67
|
-
if os.path.splitext(name)[1] in EXTENSIONS:
|
|
68
|
-
yield os.path.join(current, name)
|
|
47
|
+
def dynamic_key_pattern(types):
|
|
48
|
+
return re.compile(rf"({key_type_alternation(types)})\(")
|
|
69
49
|
|
|
70
50
|
|
|
71
|
-
def hits_in_line(line, ext):
|
|
51
|
+
def hits_in_line(line, ext, types=DEFAULT_KEY_TYPES):
|
|
72
52
|
static, dynamic = [], []
|
|
73
53
|
if ext == ".xml":
|
|
74
54
|
static.extend(m.group(1) for m in XML_STRING.finditer(line))
|
|
@@ -81,15 +61,13 @@ def hits_in_line(line, ext):
|
|
|
81
61
|
if WEB_DOLLAR_T_DYNAMIC.search(line):
|
|
82
62
|
dynamic.append("(dynamic: $t(...))")
|
|
83
63
|
return static, dynamic
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if key.count(".") >= 1:
|
|
87
|
-
static.append(key)
|
|
88
|
-
static.extend(m.group(1) for m in LITERAL_LOCALIZED.finditer(line))
|
|
64
|
+
static.extend(lsk_keys(line, types))
|
|
65
|
+
static.extend(m.group(1) for m in LITERAL_LOCALIZED.finditer(line) if not is_interpolated(m.group(1)))
|
|
89
66
|
static.extend(m.group(1) for m in R_STRING.finditer(line))
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
67
|
+
constructed = dynamic_key_pattern(types).search(line)
|
|
68
|
+
if constructed:
|
|
69
|
+
dynamic.append(f"(dynamic: {constructed.group(1)}(...))")
|
|
70
|
+
mentions_key = any(marker in line for marker in (".localized", *types))
|
|
93
71
|
if mentions_key and FORMAT_DYNAMIC.search(line):
|
|
94
72
|
dynamic.append("(dynamic: String(format:...))")
|
|
95
73
|
if "R.string." not in line and GETSTRING_DYNAMIC.search(line):
|
|
@@ -98,26 +76,30 @@ def hits_in_line(line, ext):
|
|
|
98
76
|
dynamic.append(f'(dynamic: "{match.group(1)}".localized)')
|
|
99
77
|
for match in RECEIVER_LOCALIZED.finditer(line):
|
|
100
78
|
receiver = match.group(1)
|
|
101
|
-
if not receiver.startswith(
|
|
79
|
+
if not receiver.startswith(tuple(types)):
|
|
102
80
|
dynamic.append(f"(dynamic: {receiver}.localized)")
|
|
103
81
|
return static, dynamic
|
|
104
82
|
|
|
105
83
|
|
|
106
|
-
def scan(root):
|
|
84
|
+
def scan(root, types=DEFAULT_KEY_TYPES):
|
|
107
85
|
keys = []
|
|
108
86
|
seen = set()
|
|
87
|
+
accessor_texts = []
|
|
109
88
|
scanned = 0
|
|
110
89
|
for path in source_files(root):
|
|
111
90
|
try:
|
|
112
91
|
with open(path, encoding="utf-8") as stream:
|
|
113
|
-
|
|
92
|
+
text = stream.read()
|
|
114
93
|
except (OSError, UnicodeDecodeError):
|
|
115
94
|
continue
|
|
95
|
+
lines = text.splitlines()
|
|
116
96
|
scanned += 1
|
|
117
97
|
ext = os.path.splitext(path)[1]
|
|
98
|
+
if ext in ACCESSOR_EXTENSIONS:
|
|
99
|
+
accessor_texts.append(text)
|
|
118
100
|
platform = platform_of(path)
|
|
119
101
|
for number, line in enumerate(lines, 1):
|
|
120
|
-
static, dynamic = hits_in_line(line, ext)
|
|
102
|
+
static, dynamic = hits_in_line(line, ext, types)
|
|
121
103
|
in_error = bool(ERROR_CONTEXT.search(line))
|
|
122
104
|
found = [(k, "error" if in_error else "static") for k in static] + [(k, "dynamic") for k in dynamic]
|
|
123
105
|
for key, category in found:
|
|
@@ -133,7 +115,7 @@ def scan(root):
|
|
|
133
115
|
"line": number,
|
|
134
116
|
"snippet": line.strip()[:200],
|
|
135
117
|
})
|
|
136
|
-
return scanned, keys
|
|
118
|
+
return scanned, keys, key_type_warning(accessor_texts, types)
|
|
137
119
|
|
|
138
120
|
|
|
139
121
|
CATEGORIES = ("static", "error", "dynamic")
|
|
@@ -144,22 +126,31 @@ def main():
|
|
|
144
126
|
cli.add_argument("--screen-path", required=True, help="folder or single file of the built screen")
|
|
145
127
|
cli.add_argument("--out", help="JSON destination; stdout when omitted")
|
|
146
128
|
cli.add_argument("--category", default="all", choices=["all", "error", "dynamic", "static"])
|
|
129
|
+
cli.add_argument(
|
|
130
|
+
"--key-type", action="append", metavar="NAME",
|
|
131
|
+
help="type whose member chains are keys, e.g. the project's generated key enum; repeatable or comma list "
|
|
132
|
+
f"(default {', '.join(DEFAULT_KEY_TYPES)})",
|
|
133
|
+
)
|
|
147
134
|
opts = cli.parse_args()
|
|
148
135
|
|
|
149
136
|
if not os.path.exists(opts.screen_path):
|
|
150
137
|
cli.error(f"--screen-path not found: {opts.screen_path}")
|
|
138
|
+
try:
|
|
139
|
+
types = key_types(opts.key_type)
|
|
140
|
+
except ValueError as exc:
|
|
141
|
+
cli.error(str(exc))
|
|
151
142
|
|
|
152
|
-
scanned, keys = scan(opts.screen_path)
|
|
143
|
+
scanned, keys, type_warning = scan(opts.screen_path, types)
|
|
153
144
|
if opts.category != "all":
|
|
154
145
|
keys = [entry for entry in keys if entry["category"] == opts.category]
|
|
155
146
|
keys.sort(key=lambda entry: (entry["category"], entry["file"], entry["line"]))
|
|
156
147
|
per_category = [entry["category"] for entry in keys]
|
|
157
148
|
counts = {name: per_category.count(name) for name in CATEGORIES}
|
|
158
149
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
) + "\n"
|
|
150
|
+
report = {"screenPath": opts.screen_path, "filesScanned": scanned, "counts": counts, "keys": keys}
|
|
151
|
+
if type_warning:
|
|
152
|
+
report["keyTypeWarning"] = type_warning
|
|
153
|
+
text = json.dumps(report, indent=2, ensure_ascii=False) + "\n"
|
|
163
154
|
if opts.out:
|
|
164
155
|
with open(opts.out, "w", encoding="utf-8") as sink:
|
|
165
156
|
sink.write(text)
|
|
@@ -167,6 +158,8 @@ def main():
|
|
|
167
158
|
sys.stdout.write(text)
|
|
168
159
|
summary = ", ".join(f"{counts[name]} {name}" for name in CATEGORIES)
|
|
169
160
|
print(f"# {scanned} file(s) read: {summary}", file=sys.stderr)
|
|
161
|
+
if type_warning:
|
|
162
|
+
print(f"# {type_warning}", file=sys.stderr)
|
|
170
163
|
|
|
171
164
|
|
|
172
165
|
if __name__ == "__main__":
|