Skip to content

Commit 202b171

Browse files
authored
Merge pull request #72 from r1ckstardev/feat/anti-slop-html-maintainer-ci
feat(validator): HTML-tag-mismatch + _maintainer shape + wire validate-packs into CI
2 parents 81c63d8 + 0cb2734 commit 202b171

4 files changed

Lines changed: 318 additions & 0 deletions

File tree

.github/workflows/tests.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ jobs:
3030
- name: Unit Tests
3131
run: dotnet test --configuration Release --no-build --verbosity normal --logger "trx;LogFileName=test-results.trx"
3232

33+
- name: Validate translation packs
34+
continue-on-error: true
35+
env:
36+
Translation__OutputDirectory: ${{ github.workspace }}/translations
37+
run: dotnet run --project Translator/BTCPayTranslator.csproj --configuration Release --no-build -- validate-packs
38+
3339
- name: Upload test logs
3440
if: always()
3541
uses: actions/upload-artifact@v4

Translator.Tests/Services/LanguagePackValidatorTests.cs

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,235 @@ await File.WriteAllTextAsync(filePath, $$"""
128128
}
129129
}
130130

131+
[Fact]
132+
public async Task ValidateAsync_FlagsHtmlTagMismatch()
133+
{
134+
var tempDir = CreateTempDirectory();
135+
136+
try
137+
{
138+
var filePath = Path.Combine(tempDir, "hindi.json");
139+
await File.WriteAllTextAsync(filePath, """
140+
{
141+
"<strong>Never</strong> trust anything but <code>id</code>": "केवल <code>id</code> पर भरोसा करें",
142+
"kept-intact": "<code>foo</code> bar <code>baz</code>"
143+
}
144+
""".Replace("<code>foo</code> bar <code>baz</code>",
145+
"<code>foo</code> bar <code>baz</code>"));
146+
147+
// Re-write with a balanced kept-intact entry so only the first entry fails the rule
148+
await File.WriteAllTextAsync(filePath, """
149+
{
150+
"<strong>Never</strong> trust anything but <code>id</code>": "केवल <code>id</code> पर भरोसा करें",
151+
"<code>foo</code>": "<code>foo</code>"
152+
}
153+
""");
154+
155+
var sut = CreateSut(tempDir);
156+
var result = await sut.ValidateAsync(fix: false);
157+
158+
Assert.Equal(2, result.EntriesScanned);
159+
var issue = Assert.Single(result.Issues);
160+
Assert.StartsWith("<strong>Never", issue.Key);
161+
Assert.Contains("Structural HTML tag mismatch", issue.Reason);
162+
}
163+
finally
164+
{
165+
if (Directory.Exists(tempDir))
166+
{
167+
Directory.Delete(tempDir, recursive: true);
168+
}
169+
}
170+
}
171+
172+
[Fact]
173+
public async Task ValidateAsync_IgnoresExampleEmailAngleBrackets()
174+
{
175+
// The HTML-tag check uses a curated allowlist of structural elements
176+
// (strong/em/code/br/p/a/etc.) so localized example data like
177+
// "<email@primer.com>" doesn't trip the rule even though the bare
178+
// HtmlTagRegex would match it.
179+
var tempDir = CreateTempDirectory();
180+
181+
try
182+
{
183+
var filePath = Path.Combine(tempDir, "serbian.json");
184+
await File.WriteAllTextAsync(filePath, """
185+
{
186+
"Firstname Lastname <email@example.com>": "Ime Prezime <email@primer.com>"
187+
}
188+
""");
189+
190+
var sut = CreateSut(tempDir);
191+
var result = await sut.ValidateAsync(fix: false);
192+
193+
Assert.Equal(1, result.EntriesScanned);
194+
Assert.Empty(result.Issues);
195+
}
196+
finally
197+
{
198+
if (Directory.Exists(tempDir))
199+
{
200+
Directory.Delete(tempDir, recursive: true);
201+
}
202+
}
203+
}
204+
205+
[Fact]
206+
public async Task ValidateAsync_FlagsInvalidMaintainerField()
207+
{
208+
var tempDir = CreateTempDirectory();
209+
210+
try
211+
{
212+
var filePath = Path.Combine(tempDir, "bad-maintainer.json");
213+
await File.WriteAllTextAsync(filePath, """
214+
{
215+
"_maintainer": "someone with no pipe or URL",
216+
"hello": "bonjour"
217+
}
218+
""");
219+
220+
var sut = CreateSut(tempDir);
221+
var result = await sut.ValidateAsync(fix: false);
222+
223+
// _maintainer is not counted as a translation entry
224+
Assert.Equal(1, result.EntriesScanned);
225+
var issue = Assert.Single(result.Issues);
226+
Assert.Equal("_maintainer", issue.Key);
227+
Assert.Contains("Invalid _maintainer value", issue.Reason);
228+
}
229+
finally
230+
{
231+
if (Directory.Exists(tempDir))
232+
{
233+
Directory.Delete(tempDir, recursive: true);
234+
}
235+
}
236+
}
237+
238+
[Fact]
239+
public async Task ValidateAsync_AcceptsWellFormedMaintainerField()
240+
{
241+
var tempDir = CreateTempDirectory();
242+
243+
try
244+
{
245+
var filePath = Path.Combine(tempDir, "ok-maintainer.json");
246+
await File.WriteAllTextAsync(filePath, """
247+
{
248+
"_maintainer": "thgO-O|https://github.com/thgO-O",
249+
"hello": "olá"
250+
}
251+
""");
252+
253+
var sut = CreateSut(tempDir);
254+
var result = await sut.ValidateAsync(fix: false);
255+
256+
Assert.Equal(1, result.EntriesScanned);
257+
Assert.Empty(result.Issues);
258+
}
259+
finally
260+
{
261+
if (Directory.Exists(tempDir))
262+
{
263+
Directory.Delete(tempDir, recursive: true);
264+
}
265+
}
266+
}
267+
268+
[Fact]
269+
public async Task ValidateAsync_RejectsMaintainerWithHttpScheme()
270+
{
271+
var tempDir = CreateTempDirectory();
272+
273+
try
274+
{
275+
var filePath = Path.Combine(tempDir, "http-maintainer.json");
276+
await File.WriteAllTextAsync(filePath, """
277+
{
278+
"_maintainer": "thgO-O|http://github.com/thgO-O"
279+
}
280+
""");
281+
282+
var sut = CreateSut(tempDir);
283+
var result = await sut.ValidateAsync(fix: false);
284+
285+
var issue = Assert.Single(result.Issues);
286+
Assert.Equal("_maintainer", issue.Key);
287+
Assert.Contains("Invalid _maintainer", issue.Reason);
288+
}
289+
finally
290+
{
291+
if (Directory.Exists(tempDir))
292+
{
293+
Directory.Delete(tempDir, recursive: true);
294+
}
295+
}
296+
}
297+
298+
[Fact]
299+
public async Task ValidateAsync_AcceptsNullMaintainerField()
300+
{
301+
var tempDir = CreateTempDirectory();
302+
303+
try
304+
{
305+
var filePath = Path.Combine(tempDir, "null-maintainer.json");
306+
await File.WriteAllTextAsync(filePath, """
307+
{
308+
"_maintainer": null,
309+
"hello": "hei"
310+
}
311+
""");
312+
313+
var sut = CreateSut(tempDir);
314+
var result = await sut.ValidateAsync(fix: false);
315+
316+
Assert.Equal(1, result.EntriesScanned);
317+
Assert.Empty(result.Issues);
318+
}
319+
finally
320+
{
321+
if (Directory.Exists(tempDir))
322+
{
323+
Directory.Delete(tempDir, recursive: true);
324+
}
325+
}
326+
}
327+
328+
[Fact]
329+
public async Task ValidateAsync_RejectsBlankMaintainerField_WhenPresent()
330+
{
331+
var tempDir = CreateTempDirectory();
332+
333+
try
334+
{
335+
var filePath = Path.Combine(tempDir, "blank-maintainer.json");
336+
await File.WriteAllTextAsync(filePath, """
337+
{
338+
"_maintainer": " ",
339+
"hello": "hei"
340+
}
341+
""");
342+
343+
var sut = CreateSut(tempDir);
344+
var result = await sut.ValidateAsync(fix: false);
345+
346+
Assert.Equal(1, result.EntriesScanned);
347+
var issue = Assert.Single(result.Issues);
348+
Assert.Equal("_maintainer", issue.Key);
349+
Assert.Contains("Invalid _maintainer", issue.Reason);
350+
}
351+
finally
352+
{
353+
if (Directory.Exists(tempDir))
354+
{
355+
Directory.Delete(tempDir, recursive: true);
356+
}
357+
}
358+
}
359+
131360
private static LanguagePackValidator CreateSut(string outputDirectory)
132361
{
133362
var configuration = new ConfigurationBuilder()

Translator/Services/LanguagePackValidator.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,19 @@ public async Task<ValidationResult> ValidateAsync(bool fix)
7373
{
7474
var key = property.Name;
7575
var value = property.Value?.ToString() ?? string.Empty;
76+
77+
if (key.Equals("_maintainer", StringComparison.Ordinal))
78+
{
79+
var maintainerValue = property.Value?.Type == JTokenType.Null ? null : value;
80+
81+
if (!TranslationValidationRules.IsValidMaintainerValue(maintainerValue))
82+
{
83+
issues.Add(new ValidationIssue(Path.GetFileName(filePath), key,
84+
"Invalid _maintainer value (expected '<display name or handle>|<https URL>')"));
85+
}
86+
continue;
87+
}
88+
7689
totalEntries++;
7790

7891
if (TranslationValidationRules.IsSuspiciousMetaResponse(value))
@@ -112,6 +125,14 @@ public async Task<ValidationResult> ValidateAsync(bool fix)
112125
{
113126
fileChanged |= ApplyFix(property, key, value);
114127
}
128+
continue;
129+
}
130+
131+
if (!TranslationValidationRules.HasMatchingHtmlTags(key, value))
132+
{
133+
issues.Add(new ValidationIssue(Path.GetFileName(filePath), key,
134+
"Structural HTML tag mismatch between source key and translation"));
135+
// Auto-fix is intentionally skipped here. Maintainer needs to re-anchor the markup by hand.
115136
}
116137
}
117138

Translator/Services/TranslationValidationRules.cs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ internal static class TranslationValidationRules
1313
private static readonly Regex HtmlTagRegex =
1414
new(@"<[^>]+>", RegexOptions.Compiled);
1515

16+
private static readonly Regex StructuralHtmlTagRegex =
17+
new(@"<\s*/?\s*(strong|em|b|i|u|code|pre|kbd|small|sub|sup|mark|br|p|div|span|a|ul|ol|li|h[1-6]|table|thead|tbody|tr|td|th|abbr|del|ins|q|cite|var|samp)\b[^>]*>",
18+
RegexOptions.Compiled | RegexOptions.IgnoreCase);
19+
20+
private static readonly Regex MaintainerFieldRegex =
21+
new(@"^[^|]+\|https://\S+$", RegexOptions.Compiled);
22+
1623
private static readonly Regex WhitespaceRegex =
1724
new(@"\s+", RegexOptions.Compiled);
1825

@@ -246,6 +253,41 @@ public static bool HasMatchingPlaceholders(string source, string translation)
246253
return true;
247254
}
248255

256+
/// <summary>
257+
/// Checks that the source and translation use the same multiset of structural HTML tags (case-insensitive).
258+
/// </summary>
259+
public static bool HasMatchingHtmlTags(string source, string translation)
260+
{
261+
var sourceTags = ExtractStructuralTagCounts(source);
262+
var translationTags = ExtractStructuralTagCounts(translation);
263+
264+
if (sourceTags.Count != translationTags.Count)
265+
return false;
266+
267+
foreach (var entry in sourceTags)
268+
{
269+
if (!translationTags.TryGetValue(entry.Key, out var count) || count != entry.Value)
270+
return false;
271+
}
272+
273+
return true;
274+
}
275+
276+
/// <summary>
277+
/// Validates the shape of the _maintainer field that ManifestGenerator expects
278+
/// </summary>
279+
public static bool IsValidMaintainerValue(string? value)
280+
{
281+
// if language don't have maintainer
282+
if (value is null)
283+
return true;
284+
285+
if (string.IsNullOrWhiteSpace(value))
286+
return false;
287+
288+
return MaintainerFieldRegex.IsMatch(value.Trim());
289+
}
290+
249291
public static bool IsLikelySentenceFallback(string source, string translation)
250292
{
251293
if (!string.Equals(source, translation, StringComparison.Ordinal))
@@ -300,4 +342,24 @@ private static Dictionary<string, int> ExtractTokenCounts(string text)
300342

301343
return counts;
302344
}
345+
346+
private static readonly Regex TagNameRegex = new(@"<\s*/?\s*([A-Za-z][A-Za-z0-9]*)", RegexOptions.Compiled);
347+
348+
private static Dictionary<string, int> ExtractStructuralTagCounts(string text)
349+
{
350+
var counts = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
351+
352+
foreach (Match match in StructuralHtmlTagRegex.Matches(text))
353+
{
354+
var raw = match.Value;
355+
var nameMatch = TagNameRegex.Match(raw);
356+
if (!nameMatch.Success) continue;
357+
var isClose = raw.TrimStart('<').TrimStart().StartsWith('/');
358+
var key = (isClose ? "/" : string.Empty) + nameMatch.Groups[1].Value.ToLowerInvariant();
359+
if (!counts.TryAdd(key, 1))
360+
counts[key]++;
361+
}
362+
363+
return counts;
364+
}
303365
}

0 commit comments

Comments
 (0)