Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci-guards.yml
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ jobs:
run: |
python3 tests/test_localization_catalog.py
python3 tests/test_localize_changes.py
python3 tests/test_localization_defaults.py

- name: Test the PR media uploader
if: ${{ matrix.group == 'preflight' }}
Expand Down
12 changes: 8 additions & 4 deletions docs/verification-receipts.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ base ref to include committed branch changes since its merge-base with HEAD.
`--affected` with `--swift-changed` to also parse changed Swift files, or use
`--only` instead when you want to choose checks yourself.

Selection covers the twelve checks below. Each checker declares its file inputs
Selection covers the sixteen checks below. Each checker declares its file inputs
in `CHECK_INPUTS` in `scripts/verify-local.py`; update those declarations when a
checker gains dependencies. Unknown paths select all twelve checks. Known prose
checker gains dependencies. Unknown paths select all sixteen checks. Known prose
changes omit unrelated checks, while feature-flag expiry policy always runs
because its result depends on today's date. This does not select native tests
or the separate CI workflow guards.
Expand Down Expand Up @@ -110,7 +110,7 @@ on PATH and runs `-frontend -parse -swift-version 5 -D DEBUG -enable-bare-slash-
It does not resolve imports, expand macros, typecheck, compile, execute tests, or
validate every conditional-compilation configuration. Use the intended toolchain;
parsing with a newer compiler does not prove compatibility with an older one.
The default Linux CI recipe remains the ten portable checks below.
The default Linux CI recipe remains the sixteen portable checks below.

Receipts record the parser version, exact argv, selected-file hashes before and
after, selection origin/resolved base, and a separate `parsing` result. Missing
Expand All @@ -133,10 +133,14 @@ it does not assert that the entire CI checkout equals the PR head.
| --- | --- |
| `xcstrings` | Localization catalog structure |
| `localization` | macOS localization parity |
| `localization-defaults` | Swift `defaultValue` literals consume the same format arguments as their catalog `en` value |
Comment thread
teamleaderleo marked this conversation as resolved.
| `project-tests` | Five project normalizer unit tests at the demonstrated revision; counts are read from each execution |
| `project` | Xcode project version pin and normalization |
| `config-schema` | Embedded cmux.json schema matches its source |
| `test-wiring-sync` | Test-wiring synchronization tool regression suite |
| `wire-app-sources` | App-source wiring tool regression suite |
| `ui-lab` | ui-lab harness directive regression suite |
| `ui-fuzzer` | UI fuzzer engine regression suite |
| `launch-policy` | Generated Claude launch policy is current |
| `test-wiring` | Every Swift test file belongs to the Xcode test target |
| `package-groups` | Workspace Swift package grouping |
Expand All @@ -145,7 +149,7 @@ it does not assert that the entire CI checkout equals the PR head.
| `feature-flags` | Flag names, ownership, expiry, defaults, single evaluation and retired keys |

Each failure prints a bounded diagnostic tail and an exact `--only` rerun command.
The default runs all twelve checks so one pass reveals independent failures.
The default runs all sixteen checks so one pass reveals independent failures.
`--only` runs the named subset and says which checks actually ran; it does not
infer affected tests from a diff. `--repo` targets another checkout. Each check
has a 60-second deadline, adjustable with `--timeout`; Ctrl-C stops the active
Expand Down
5 changes: 5 additions & 0 deletions scripts/localization-default-mismatches.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"cloudTree.error.renameTerminalUnavailable": "Swift passes the terminal id to String(format:) but the catalog copy has no %@ and is terminal-scoped on a path that renames any tab kind; needs a copy decision (#15864).",
"remoteDaemon.upload.installFailedWithDetail": "Catalog copy ends in ': %@' but the Swift call site neither includes the specifier in its defaultValue nor formats the detail; fix the call site, then remove this entry (#15864).",
"remoteDaemon.bootstrap.removeCorruptFailedWithDetail": "Catalog copy ends in ': %@' but the Swift call site neither includes the specifier in its defaultValue nor formats the detail; fix the call site, then remove this entry (#15864)."
}
157 changes: 157 additions & 0 deletions scripts/localization_defaults.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
"""Compare Swift `defaultValue` literals with the catalog English they localize (Python 3.9+).

`localization_catalog.py check` takes its baseline from the catalog itself: every
locale is compared with the entry's own `en` value. A catalog `en` value that lost
or gained a format specifier relative to the Swift `defaultValue` therefore passes,
because every locale agrees with `en` and nothing reads the Swift source. At
runtime `String(format:)` then silently drops the argument, or reads past the
argument list. This check reads the Swift source and compares the two signatures.

Known mismatches that are waiting on a copy decision live in
scripts/localization-default-mismatches.json, keyed by catalog key with a reason.
An entry that no longer mismatches is an error, so the list only shrinks.
"""

from __future__ import annotations

import argparse
import importlib.util
import json
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
ALLOWLIST = "scripts/localization-default-mismatches.json"
# Test targets and fixtures use catalog keys with their own default text on
# purpose; vendored and generated trees are not cmux copy.
SKIP_DIRS = frozenset((
".build", ".git", "DerivedData", "Examples", "Prototypes", "Tests", "artifacts",
"cmuxCLITestSupport", "cmuxCLITests", "cmuxTests", "cmuxUITests", "experiments",
"node_modules", "scripts", "tests", "tests_v2", "vendor",
))


def load_changes_module():
spec = importlib.util.spec_from_file_location("cmux_localize_changes", ROOT / "scripts/localize_changes.py")
if spec is None or spec.loader is None:
raise RuntimeError("unable to load scripts/localize_changes.py")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module


CHANGES = load_changes_module()
CATALOG = CHANGES.CATALOG


def swift_files(root: Path) -> list[Path]:
return sorted(path for path in root.rglob("*.swift")
if SKIP_DIRS.isdisjoint(path.relative_to(root).parts[:-1]))


def swift_defaults(root: Path) -> dict[str, tuple[str, str]]:
"""Map each key to (defaultValue, Swift path).

Call sites that disagree about a key's default are dropped, as are calls the
shared extractor cannot read (interpolated or unsupported literals); both are
already reported by localize_changes.py, and neither yields one signature.
"""
defaults: dict[str, tuple[str, str]] = {}
conflicts: set[str] = set()
for path in swift_files(root):
relative = path.relative_to(root).as_posix()
messages, _ = CHANGES.parse_swift_messages(relative, path.read_text(encoding="utf-8"), conflicts=conflicts)
for key, message in messages.items():
previous = defaults.get(key)
if previous is not None and previous[0] != message.source:
conflicts.add(key)
else:
defaults[key] = (message.source, relative)
for key in conflicts:
defaults.pop(key, None)
return defaults


def catalog_index(root: Path) -> dict[str, list[tuple[str, object]]]:
index: dict[str, list[tuple[str, object]]] = {}
for path in CATALOG.discover(root):
for entry in CATALOG.catalog_entries(path.read_text(encoding="utf-8")):
index.setdefault(entry.key, []).append((path.relative_to(root).as_posix(), entry.value))
return index


def load_allowlist(path: Path) -> dict[str, str]:
if not path.is_file():
return {}
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict) or not all(isinstance(reason, str) and reason.strip() for reason in data.values()):
raise ValueError(f"{path}: expected an object mapping catalog keys to a non-empty reason")
return data


def arguments(text: str) -> list[tuple[int, str]]:
"""The (argument, specifier) pairs a format string consumes, in argument order."""
return sorted(CATALOG.signature(text))


def check(root: Path, allowlist: dict[str, str]) -> tuple[list[str], int]:
"""Return (errors, comparison count); a key carried by several catalogs is compared with each."""
errors: list[str] = []
index = catalog_index(root)
compared = 0
still_mismatched: set[str] = set()
for key, (default, swift_path) in sorted(swift_defaults(root).items()):
catalogs = []
for catalog_path, entry in index.get(key, []):
Comment thread
teamleaderleo marked this conversation as resolved.
try:
catalogs.append((catalog_path, arguments(CATALOG.source(entry))))
except ValueError:
continue # localization_catalog.py check already names a malformed entry
try:
expected = arguments(default)
except ValueError:
continue # e.g. a %#@name@ substitution in Swift text; localize_changes.py owns that review
for catalog_path, actual in catalogs:
compared += 1
if expected == actual:
continue
if key in allowlist:
still_mismatched.add(key)
continue
# The same key can live in more than one product's catalog with
# different copy; name the siblings so the fix lands in the right one.
siblings = "".join(f"; {other} carries {sibling!r}" for other, sibling in catalogs if other != catalog_path)
errors.append(
f"{catalog_path}:{key}: catalog en placeholders {actual!r} != "
f"Swift defaultValue {expected!r} ({swift_path}){siblings}"
)
for key in sorted(set(allowlist) - still_mismatched):
errors.append(f"{ALLOWLIST}: {key} no longer mismatches its Swift defaultValue; remove the entry")
return errors, compared


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--root", type=Path, default=ROOT)
parser.add_argument("--allowlist", type=Path, help=f"default: <root>/{ALLOWLIST}")
parser.add_argument("--limit", type=int, default=40, help="maximum printed diagnostics; 0 prints all")
args = parser.parse_args(argv)
if args.limit < 0:
parser.error("limit must be nonnegative")
root = args.root.resolve()
errors, compared = check(root, load_allowlist(args.allowlist or root / ALLOWLIST))
for error in errors[:args.limit or None]:
print(error, file=sys.stderr)
noun = "mismatch" if len(errors) == 1 else "mismatches"
print(f"{compared} Swift defaultValue/catalog en comparisons: {len(errors)} {noun}")
return int(bool(errors))


if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, ValueError, KeyError, TypeError) as error:
print(f"error: {error}", file=sys.stderr)
raise SystemExit(2)
12 changes: 10 additions & 2 deletions scripts/localize_changes.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,10 +201,18 @@ def swift_call_suffix(text: str, start: int) -> str:
return ""


def parse_swift_messages(path: str, text: str) -> tuple[dict[str, SwiftMessage], list[str]]:
def parse_swift_messages(path: str, text: str, *,
conflicts: set[str] | None = None) -> tuple[dict[str, SwiftMessage], list[str]]:
"""Map each key to its one message; keys whose call sites disagree are dropped.

Pass ``conflicts`` to learn which keys were dropped: they are only named in
``attention`` otherwise, and a caller merging several files must not let one
file's single default stand in for another file's disagreement.
"""
messages: dict[str, SwiftMessage] = {}
attention: list[str] = []
conflicts: set[str] = set()
if conflicts is None:
conflicts = set()
handled = 0
for match in SWIFT_CALL.finditer(text):
suffix = swift_call_suffix(text, match.end())
Expand Down
4 changes: 4 additions & 0 deletions scripts/verify-local.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
CHECKS = (
("xcstrings", "static_analysis", "XCStrings structure", ["python3", "scripts/lint-xcstrings.py"]),
("localization", "static_analysis", "Localization parity", ["python3", "scripts/localization_catalog.py", "check"]),
("localization-defaults", "static_analysis", "Swift defaultValue parity", ["python3", "scripts/localization_defaults.py"]),
("project-tests", "tests", "Project normalizer tests", ["python3", "tests/test_normalize_pbxproj.py"]),
("project", "static_analysis", "Xcode project normalization and version", ["bash", "scripts/check-pbxproj.sh"]),
("config-schema", "static_analysis", "Embedded cmux.json schema", ["python3", "scripts/generate-cmux-config-schema.py", "--check"]),
Expand All @@ -48,6 +49,9 @@
"xcstrings": ("*.xcstrings",),
"localization": ("*.xcstrings", "scripts/localization-allowed-omissions.json",
"scripts/localization-plurals.json"),
"localization-defaults": ("*.xcstrings", "Sources/*", "Packages/*", "CLI/*", "ios/*", "TunnelExtension/*",
"scripts/localization_catalog.py", "scripts/localize_changes.py",
"scripts/localization-default-mismatches.json"),
"project-tests": ("scripts/normalize-pbxproj.py", "scripts/check-pbxproj-group-membership.py"),
"project": ("scripts/normalize-pbxproj.py", "scripts/check-pbxproj-group-membership.py",
"cmux.xcodeproj/project.pbxproj",
Expand Down
4 changes: 4 additions & 0 deletions tests/test-execution.toml
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,10 @@ lane = "linux-guard"
path = "tests/test_localize_changes.py"
lane = "linux-guard"

[[test]]
path = "tests/test_localization_defaults.py"
lane = "linux-guard"

[[test]]
path = "tests/test_package_resolved_policy_remote_inputs.py"
lane = "linux-guard"
Expand Down
Loading
Loading