test(l10n): per-catalog attribution and file:line output for the coverage guard (#1679)

* test(l10n): report file and line for an untranslated key

The catalog guard named only the file a missing key was written in, so
finding it in a 900-line view meant grepping. Collect each literal
`String(localized:)` reference with its repo-relative path and line, and
print one `path:line key` per line instead of a comma-joined blob.

* test(l10n): check each target against the catalog it bundles

The guard accepted a key found in either catalog. The share extension
bundles only its own, so a key it references that lives solely in the app
catalog ships English there just as surely as one that exists nowhere -
which is the gap that let the share-extension strings drift.

Check `bitchat/` against the app catalog and `bitchatShareExtension/`
against the extension catalog, and say in the failure which of the two
cases it is.

* test: check shared sources against the extension catalog too

bitchat/Services/SharedContentHandoff.swift and bitchat/Services/
TransportConfig.swift are compiled into bitchatShareExtension as well
(project.pbxproj membership exceptions), so a key they reference has to
resolve in both catalogs -- each target bundles only its own.

The exception list is read from project.pbxproj rather than hardcoded,
so the check follows the set when it changes.
This commit is contained in:
Taksh Kothari 2026-09-24 13:04:58 +05:30 committed by GitHub
parent d01c02788f
commit 711318787c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

View File

@ -71,43 +71,139 @@ struct LocalizationCoverageTests {
}
/// The catalog tests above validate the CATALOG; this one validates the
/// CODE. A `String(localized:)` whose key is absent from every catalog
/// compiles and runs fine — it just silently ships its English
/// `defaultValue` to all 29 non-source locales. That blind spot let the
/// entire notices/board composer (10 keys), two delivery states, and two
/// media-failure reasons go untranslated while the coverage tests stayed
/// green. Interpolated keys can't be checked statically and are skipped;
/// every literal key must resolve.
@Test func everyCodeReferencedKeyExistsInACatalog() throws {
let main = try Self.loadCatalog("bitchat/Localizable.xcstrings")
let shareExt = try Self.loadCatalog("bitchatShareExtension/Localization/Localizable.xcstrings")
let knownKeys = Set(main.coverage.keys).union(shareExt.coverage.keys)
/// CODE. A `String(localized:)` whose key is absent from the catalog its
/// target actually ships compiles and runs fine — it just silently ships
/// its English `defaultValue` to all 29 non-source locales. That blind
/// spot let the entire notices/board composer (10 keys), two delivery
/// states, and two media-failure reasons go untranslated while the
/// coverage tests stayed green. Interpolated keys can't be checked
/// statically and are skipped; every literal key must resolve.
///
/// The catalogs are checked per target, not as a union: the share
/// extension bundles only its own catalog, so a key it references that
/// lives solely in the app catalog is just as untranslated there as one
/// that exists nowhere.
@Test func everyCodeReferencedKeyExistsInItsOwnCatalog() throws {
let main = try Self.loadCatalog(Self.mainCatalogPath)
let shareExt = try Self.loadCatalog(Self.shareExtensionCatalogPath)
let mainKeys = Set(main.coverage.keys)
let shareKeys = Set(shareExt.coverage.keys)
let pattern = try NSRegularExpression(pattern: #"String\(\s*localized:\s*"([^"\\]+)""#)
var missing: [String] = []
// Files under `bitchat/` that the share extension also compiles have to
// resolve in both catalogs, since each target bundles only its own.
let sharedSources = try Self.shareExtensionMembershipExceptions()
for sourceRoot in ["bitchat", "bitchatShareExtension"] {
let rootURL = Self.repoRoot.appendingPathComponent(sourceRoot)
let enumerator = try #require(FileManager.default.enumerator(
at: rootURL,
includingPropertiesForKeys: nil
))
for case let fileURL as URL in enumerator where fileURL.pathExtension == "swift" {
let source = try String(contentsOf: fileURL, encoding: .utf8)
let range = NSRange(source.startIndex..., in: source)
pattern.enumerateMatches(in: source, range: range) { match, _, _ in
guard let match, let keyRange = Range(match.range(at: 1), in: source) else { return }
let key = String(source[keyRange])
if !knownKeys.contains(key) {
missing.append("\(key) (\(fileURL.lastPathComponent))")
}
for (sourceRoot, ownKeys, otherKeys, ownCatalog, otherCatalog) in [
("bitchat", mainKeys, shareKeys, Self.mainCatalogPath, Self.shareExtensionCatalogPath),
("bitchatShareExtension", shareKeys, mainKeys,
Self.shareExtensionCatalogPath, Self.mainCatalogPath)
] {
let references = try Self.localizedKeyReferences(in: sourceRoot)
let missing = references
.filter { !ownKeys.contains($0.key) }
.map { reference -> String in
let note = otherKeys.contains(reference.key)
? " (present in \(otherCatalog), which this target does not bundle)"
: " (present in no catalog)"
return "\(reference.location) \(reference.key)\(note)"
}
}
}
#expect(
missing.isEmpty,
"keys referenced in code but absent from every catalog — these ship English to all non-source locales: \(missing.sorted().joined(separator: ", "))"
#expect(
missing.isEmpty,
"""
keys referenced under \(sourceRoot)/ but absent from \(ownCatalog) — \
these ship English to all non-source locales:
\(missing.sorted().joined(separator: "\n"))
"""
)
guard sourceRoot == "bitchat" else { continue }
let missingInBoth = references
.filter { sharedSources.contains($0.location.prefix(while: { $0 != ":" }).description) }
.filter { !shareKeys.contains($0.key) }
.map { "\($0.location) \($0.key)" }
#expect(
missingInBoth.isEmpty,
"""
keys referenced from files the share extension also compiles, but absent \
from \(Self.shareExtensionCatalogPath) — these ship English inside the \
extension:
\(missingInBoth.sorted().joined(separator: "\n"))
"""
)
}
}
/// The files under `bitchat/` that the share-extension target compiles, read
/// from the project's membership exceptions so this cannot drift when the
/// set changes.
private static func shareExtensionMembershipExceptions() throws -> Set<String> {
let project = try String(
contentsOf: repoRoot.appendingPathComponent("bitchat.xcodeproj/project.pbxproj"),
encoding: .utf8
)
let marker = #"Exceptions for "bitchat" folder in "bitchatShareExtension" target"#
guard let markerRange = project.range(of: marker),
let listStart = project.range(
of: "membershipExceptions = (", range: markerRange.upperBound..<project.endIndex
),
let listEnd = project.range(
of: ");", range: listStart.upperBound..<project.endIndex
)
else { return [] }
return Set(
project[listStart.upperBound..<listEnd.lowerBound]
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { $0.hasSuffix(".swift") }
.map { "bitchat/\($0)" }
)
}
private static let mainCatalogPath = "bitchat/Localizable.xcstrings"
private static let shareExtensionCatalogPath =
"bitchatShareExtension/Localization/Localizable.xcstrings"
/// A literal `String(localized:)` key, with where it was written.
private struct KeyReference {
let key: String
/// `path/to/File.swift:line`, relative to the repository root.
let location: String
}
/// Every literal `String(localized:)` key under `sourceRoot`, with its
/// repo-relative file and line so a failure points at the call site
/// instead of just naming a file.
private static func localizedKeyReferences(in sourceRoot: String) throws -> [KeyReference] {
let pattern = try NSRegularExpression(pattern: #"String\(\s*localized:\s*"([^"\\]+)""#)
let rootURL = repoRoot.appendingPathComponent(sourceRoot)
let enumerator = try #require(FileManager.default.enumerator(
at: rootURL,
includingPropertiesForKeys: nil
))
var references: [KeyReference] = []
for case let fileURL as URL in enumerator where fileURL.pathExtension == "swift" {
let source = try String(contentsOf: fileURL, encoding: .utf8)
let relativePath = fileURL.path.replacingOccurrences(
of: repoRoot.path + "/", with: ""
)
let range = NSRange(source.startIndex..., in: source)
pattern.enumerateMatches(in: source, range: range) { match, _, _ in
guard let match,
let keyRange = Range(match.range(at: 1), in: source),
let matchStart = Range(match.range, in: source)?.lowerBound
else { return }
let line = source[source.startIndex..<matchStart].count(where: { $0 == "\n" }) + 1
references.append(KeyReference(
key: String(source[keyRange]),
location: "\(relativePath):\(line)"
))
}
}
return references
}
}