Fix three defects of the English-comments check found in the trial (task 8) #212

Merged
Tirsvad merged 3 commits from mil-013-judge-each-line into main 2026-10-21 05:47:53 +02:00
9 changed files with 88 additions and 30 deletions
+3 -2
View File
@@ -100,14 +100,15 @@ jobs:
--output "$FINDINGS_FILE" --output "$FINDINGS_FILE"
- name: Tell the author - name: Tell the author
if: ${{ always() && gitea.event_name == 'pull_request' }} # in a called workflow gitea.event_name is workflow_call, so ask for the pull request itself
if: ${{ always() && gitea.event.pull_request.number != '' }}
continue-on-error: true continue-on-error: true
env: env:
GITEA_API_URL: ${{ gitea.api_url }} GITEA_API_URL: ${{ gitea.api_url }}
SOURCE_REPOSITORY: ${{ gitea.repository }} SOURCE_REPOSITORY: ${{ gitea.repository }}
PR_NUMBER: ${{ gitea.event.pull_request.number }} PR_NUMBER: ${{ gitea.event.pull_request.number }}
PR_AUTHOR: ${{ gitea.event.pull_request.user.login }} PR_AUTHOR: ${{ gitea.event.pull_request.user.login }}
RUN_URL: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_number }} RUN_URL: ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}
REPORT_TOKEN: ${{ secrets.REPORT_TOKEN || gitea.token }} REPORT_TOKEN: ${{ secrets.REPORT_TOKEN || gitea.token }}
run: python3 shared-scripts/tools/english_comments_report.py run: python3 shared-scripts/tools/english_comments_report.py
@@ -52,6 +52,7 @@ A comment is judged **not English** when `langid` 1.1.6, restricted to `en da no
- drop every token that is not a word: a token with a code character (`/ \ { } [ ] " ` = < > | $ @ # % & + ^ ~ *`), an underscore, a digit or `::` (a path, a URL, a JSON key, an identifier); a token that starts with `-` (an option), contains `.` (a file name), is written in capitals (an acronym or a constant) or is camelCase; a leading label such as `Usage:` or `Note:`; and the words of the allowed-words file. Split what is left on `-`; - drop every token that is not a word: a token with a code character (`/ \ { } [ ] " ` = < > | $ @ # % & + ^ ~ *`), an underscore, a digit or `::` (a path, a URL, a JSON key, an identifier); a token that starts with `-` (an option), contains `.` (a file name), is written in capitals (an acronym or a constant) or is camelCase; a leading label such as `Usage:` or `Note:`; and the words of the allowed-words file. Split what is left on `-`;
- keep a token only if it is letters, with at most one inner apostrophe (letters include `æ ø å ä ö ü` and the other letters of Latin scripts); - keep a token only if it is letters, with at most one inner apostrophe (letters include `æ ø å ä ö ü` and the other letters of Latin scripts);
- a block of several consecutive comment lines is judged **line by line first**, each line only with at least four words and a probability of at least 0.7, so an English line cannot hide a Danish one next to it (the first version judged the joined block and missed exactly that case in the trial of [MIL-013] task 8); when no line is flagged, the joined block is judged with the values above, so a sentence cut over short lines is not missed. A single comment line is judged once, with the values above;
- a comment with fewer than three words left is **not judged** (it passes), and so is a comment that holds only identifiers, URLs or numbers. - a comment with fewer than three words left is **not judged** (it passes), and so is a comment that holds only identifiers, URLs or numbers.
On the corpus this gives 0 false positives in 300 English comments, Danish 44/45 (97.8 %), Malay 41/45 (91.1 %), and 48/48 for the other languages, meeting criterion 1 of [MIL-013]. The threshold, the minimum of three words and the candidate languages are inputs of the workflow with these values as defaults; the rates are a test that fails when the code changes them (task 7). On the corpus this gives 0 false positives in 300 English comments, Danish 44/45 (97.8 %), Malay 41/45 (91.1 %), and 48/48 for the other languages, meeting criterion 1 of [MIL-013]. The threshold, the minimum of three words and the candidate languages are inputs of the workflow with these values as defaults; the rates are a test that fails when the code changes them (task 7).
+5 -2
View File
@@ -1,4 +1,4 @@
<!-- translation of EnglishComments.md, source sha256 866ce29fd1ea --> <!-- translation of EnglishComments.md, source sha256 98f3d6e44d62 -->
> Dette er en oversættelse af [`EnglishComments`](EnglishComments). Den engelske side er autoritativ, og ændringer her bliver overskrevet, næste gang wikien publiceres. > Dette er en oversættelse af [`EnglishComments`](EnglishComments). Den engelske side er autoritativ, og ændringer her bliver overskrevet, næste gang wikien publiceres.
@@ -52,7 +52,10 @@ kommentar.
Kommentarerne og docstrings i filer på de sprog, kodetælleren kender Kommentarerne og docstrings i filer på de sprog, kodetælleren kender
([kodemålinger](CodeMetrics)): Python, shell, PowerShell, C, C++, C#, Java, ([kodemålinger](CodeMetrics)): Python, shell, PowerShell, C, C++, C#, Java,
JavaScript, TypeScript, Go, Rust og SQL. Strenge og kode læses ikke. Kommentarlinjer JavaScript, TypeScript, Go, Rust og SQL. Strenge og kode læses ikke. Kommentarlinjer
efter hinanden bedømmes som én blok. efter hinanden danner en blok. Hver linje i en blok bedømmes for sig (mindst fire ord og en
sandsynlighed på mindst 0,7), så en engelsk linje ikke kan skjule en dansk ved siden af; når ingen linje
markeres, bedømmes blokken som helhed (mindst `min-words` ord og `threshold`), så en sætning delt over korte
linjer ikke overses.
Før en blok bedømmes, fjernes alt, der ikke er et ord: stier, URL'er, tilvalg Før en blok bedømmes, fjernes alt, der ikke er et ord: stier, URL'er, tilvalg
(`--output`), filnavne, identifikatorer (`snake_case`, `camelCase`), tal, (`--output`), filnavne, identifikatorer (`snake_case`, `camelCase`), tal,
+4 -1
View File
@@ -51,7 +51,10 @@ the check still fails the run, but no comment is written.
The comments and docstrings of the files of the languages in the code counter The comments and docstrings of the files of the languages in the code counter
([code metrics](CodeMetrics)): Python, shell, PowerShell, C, C++, C#, Java, ([code metrics](CodeMetrics)): Python, shell, PowerShell, C, C++, C#, Java,
JavaScript, TypeScript, Go, Rust and SQL. Strings and code are not read. JavaScript, TypeScript, Go, Rust and SQL. Strings and code are not read.
Consecutive comment lines are judged as one block. Consecutive comment lines form a block. Each line of a block is judged on its own
(at least four words and a probability of at least 0.7), so an English line cannot
hide a Danish one next to it; when no line is flagged, the block is judged as a whole
(at least `min-words` words and `threshold`), so a sentence cut over short lines is not missed.
Before a block is judged, everything that is not a word is removed: paths, URLs, Before a block is judged, everything that is not a word is removed: paths, URLs,
options (`--output`), file names, identifiers (`snake_case`, `camelCase`), options (`--output`), file names, identifiers (`snake_case`, `camelCase`),
+5 -2
View File
@@ -1,4 +1,4 @@
<!-- translation of EnglishComments.md, source sha256 866ce29fd1ea --> <!-- translation of EnglishComments.md, source sha256 98f3d6e44d62 -->
> Ini ialah terjemahan [`EnglishComments`](EnglishComments). Halaman bahasa Inggeris ialah teks yang sah, dan perubahan di sini akan ditimpa pada penerbitan wiki yang seterusnya. > Ini ialah terjemahan [`EnglishComments`](EnglishComments). Halaman bahasa Inggeris ialah teks yang sah, dan perubahan di sini akan ditimpa pada penerbitan wiki yang seterusnya.
@@ -52,7 +52,10 @@ semakan masih menggagalkan larian, tetapi tiada komen ditulis.
Komen dan docstring dalam fail bahasa yang dikenali oleh pengira kod Komen dan docstring dalam fail bahasa yang dikenali oleh pengira kod
([metrik kod](CodeMetrics)): Python, shell, PowerShell, C, C++, C#, Java, ([metrik kod](CodeMetrics)): Python, shell, PowerShell, C, C++, C#, Java,
JavaScript, TypeScript, Go, Rust dan SQL. String dan kod tidak dibaca. Baris komen JavaScript, TypeScript, Go, Rust dan SQL. String dan kod tidak dibaca. Baris komen
yang berturutan dinilai sebagai satu blok. yang berturutan membentuk satu blok. Setiap baris dalam blok dinilai secara berasingan (sekurang-kurangnya
empat perkataan dan kebarangkalian sekurang-kurangnya 0.7), jadi baris bahasa Inggeris tidak dapat menyembunyikan
baris bahasa Denmark di sebelahnya; apabila tiada baris ditandakan, blok dinilai secara keseluruhan
(sekurang-kurangnya `min-words` perkataan dan `threshold`), supaya ayat yang dipecahkan kepada baris pendek tidak terlepas.
Sebelum sesuatu blok dinilai, segala yang bukan perkataan dibuang: laluan, URL, Sebelum sesuatu blok dinilai, segala yang bukan perkataan dibuang: laluan, URL,
pilihan (`--output`), nama fail, pengecam (`snake_case`, `camelCase`), nombor, pilihan (`--output`), nama fail, pengecam (`snake_case`, `camelCase`), nombor,
+4 -1
View File
@@ -45,7 +45,10 @@ the check still fails the run, but no comment is written.
The comments and docstrings of the files of the languages in the code counter The comments and docstrings of the files of the languages in the code counter
([code metrics](code-metrics.md)): Python, shell, PowerShell, C, C++, C#, Java, ([code metrics](code-metrics.md)): Python, shell, PowerShell, C, C++, C#, Java,
JavaScript, TypeScript, Go, Rust and SQL. Strings and code are not read. JavaScript, TypeScript, Go, Rust and SQL. Strings and code are not read.
Consecutive comment lines are judged as one block. Consecutive comment lines form a block. Each line of a block is judged on its own
(at least four words and a probability of at least 0.7), so an English line cannot
hide a Danish one next to it; when no line is flagged, the block is judged as a whole
(at least `min-words` words and `threshold`), so a sentence cut over short lines is not missed.
Before a block is judged, everything that is not a word is removed: paths, URLs, Before a block is judged, everything that is not a word is removed: paths, URLs,
options (`--output`), file names, identifiers (`snake_case`, `camelCase`), options (`--output`), file names, identifiers (`snake_case`, `camelCase`),
+16 -2
View File
@@ -114,9 +114,9 @@ class TestExtract:
def test_a_file_of_an_unknown_language_has_no_comments(self): def test_a_file_of_an_unknown_language_has_no_comments(self):
assert ec.extract("notes.md", "# Heading\n") == [] assert ec.extract("notes.md", "# Heading\n") == []
def test_consecutive_lines_are_one_block(self): def test_consecutive_lines_are_one_group(self):
lines = [(1, "a"), (2, "b"), (4, "c"), (5, "d"), (5, "e")] lines = [(1, "a"), (2, "b"), (4, "c"), (5, "d"), (5, "e")]
assert ec.blocks(lines) == [(1, 2, "a b"), (4, 5, "c d e")] assert ec.groups(lines) == [[(1, "a"), (2, "b")], [(4, "c"), (5, "d"), (5, "e")]]
class TestCheckText: class TestCheckText:
@@ -134,6 +134,20 @@ class TestCheckText:
assert [f.line for f in ec.check_text("a.py", text, {3})] == [3] assert [f.line for f in ec.check_text("a.py", text, {3})] == [3]
assert ec.check_text("a.py", text, {2}) == [] assert ec.check_text("a.py", text, {2}) == []
def test_a_danish_line_next_to_an_english_line_is_still_found(self):
english = "# This function adds the totals of the basket together and returns the sum\n"
findings = ec.check_text("a.py", english + self.DANISH)
assert [(f.line, f.end_line, f.language) for f in findings] == [(2, 2, "da")]
def test_a_sentence_cut_over_short_lines_is_judged_as_a_whole(self):
text = "# Denne funktion\n# beregner totalen\n# for kurven\n# og gemmer resultatet\n"
[finding] = ec.check_text("a.py", text)
assert (finding.line, finding.end_line, finding.language) == (1, 4, "da")
def test_only_a_flagged_line_is_reported_when_the_block_is_mixed(self):
text = self.DANISH + "# and this line is plain English text about the totals\n"
assert [f.line for f in ec.check_text("a.py", text, {2})] == []
def test_the_marker_skips_the_block(self): def test_the_marker_skips_the_block(self):
text = self.DANISH.rstrip() + " # english-comments: ignore\n" text = self.DANISH.rstrip() + " # english-comments: ignore\n"
assert ec.check_text("a.py", text) == [] assert ec.check_text("a.py", text) == []
+11
View File
@@ -262,6 +262,17 @@ class TestWorkflow:
assert tell["continue-on-error"] is True and "always()" in tell["if"] assert tell["continue-on-error"] is True and "always()" in tell["if"]
assert "steps.check.outcome == 'failure'" in steps["Fail the run when a comment is not in English"]["if"] assert "steps.check.outcome == 'failure'" in steps["Fail the run when a comment is not in English"]["if"]
def test_the_author_step_asks_for_the_pull_request_not_for_the_event_name(self, document):
# in a called workflow gitea.event_name is workflow_call (seen on the instance, MIL-013 task 8)
tell = next(s for s in document["jobs"]["english-comments"]["steps"] if s.get("name") == "Tell the author")
assert "event_name" not in tell["if"]
assert "gitea.event.pull_request.number != ''" in tell["if"]
def test_the_link_to_the_run_uses_the_run_id(self, document):
# the URL of a run on the instance holds its id, not its number (MIL-013 task 8)
tell = next(s for s in document["jobs"]["english-comments"]["steps"] if s.get("name") == "Tell the author")
assert tell["env"]["RUN_URL"].endswith("/actions/runs/${{ gitea.run_id }}")
def test_inputs_reach_the_scripts_as_environment_variables_only(self, document): def test_inputs_reach_the_scripts_as_environment_variables_only(self, document):
for step in document["jobs"]["english-comments"]["steps"]: for step in document["jobs"]["english-comments"]["steps"]:
assert "inputs." not in step.get("run", "") assert "inputs." not in step.get("run", "")
+39 -20
View File
@@ -1,11 +1,12 @@
"""Find comments in source code that are not written in English (MIL-013, ADR-0012). """Find comments in source code that are not written in English (MIL-013, ADR-0012).
Run by .gitea/workflows/english-comments.yml. It reads the comments and docstrings Run by .gitea/workflows/english-comments.yml. It reads the comments and docstrings
of the source files of the languages of ADR-0010, joins the lines of one comment of the source files of the languages of ADR-0010 and judges each comment line
block, and judges the block with ``langid`` (restricted to a few candidate with ``langid`` (restricted to a few candidate languages) after removing
languages) after removing everything that is not a word: paths, options, everything that is not a word: paths, options, identifiers, numbers and the words
identifiers, numbers and the words of the allowed-words file. A block with fewer of the allowed-words file. When no line of a block of consecutive comment lines
than ``--min-words`` words left is not judged. is flagged, the block is judged as a whole, so a sentence cut over short lines is
not missed. A text with fewer than ``--min-words`` words left is not judged.
Usage: Usage:
python tools/english_comments.py check [--directory DIR] [--scope changed|all] python tools/english_comments.py check [--directory DIR] [--scope changed|all]
@@ -38,6 +39,9 @@ from pathlib import Path
DEFAULT_THRESHOLD = 0.6 DEFAULT_THRESHOLD = 0.6
DEFAULT_MIN_WORDS = 3 DEFAULT_MIN_WORDS = 3
# a single line of a longer block is judged more strictly than a whole block (ADR-0012)
LINE_THRESHOLD = 0.7
LINE_MIN_WORDS = 4
MAX_FILE_BYTES = 1_000_000 MAX_FILE_BYTES = 1_000_000
CANDIDATES = ["en", "da", "no", "sv", "de", "fr", "ms", "id", "nl"] CANDIDATES = ["en", "da", "no", "sv", "de", "fr", "ms", "id", "nl"]
LANGUAGE_NAMES = { LANGUAGE_NAMES = {
@@ -149,16 +153,15 @@ def extract(path: str, text: str) -> list[tuple[int, str]]:
return sorted((n, t) for n, t in found if t and not _PRAGMA.match(t)) return sorted((n, t) for n, t in found if t and not _PRAGMA.match(t))
def blocks(lines: list[tuple[int, str]]) -> list[tuple[int, int, str]]: def groups(lines: list[tuple[int, str]]) -> list[list[tuple[int, str]]]:
"""Join comment lines on consecutive line numbers: (first line, last line, text).""" """Group comment lines on consecutive line numbers: one group is one comment block."""
joined: list[tuple[int, int, str]] = [] found: list[list[tuple[int, str]]] = []
for number, text in lines: for number, text in lines:
if joined and joined[-1][1] in (number, number - 1): if found and found[-1][-1][0] in (number, number - 1):
first, _, previous = joined[-1] found[-1].append((number, text))
joined[-1] = (first, number, f"{previous} {text}")
else: else:
joined.append((number, number, text)) found.append([(number, text)])
return joined return found
def words(text: str, allowed: frozenset[str] = frozenset()) -> list[str]: def words(text: str, allowed: frozenset[str] = frozenset()) -> list[str]:
@@ -169,7 +172,7 @@ def words(text: str, allowed: frozenset[str] = frozenset()) -> list[str]:
tokens = tokens[1:] # a label such as "Usage:" or "Note:" is not prose tokens = tokens[1:] # a label such as "Usage:" or "Note:" is not prose
for token in tokens: for token in tokens:
if len(token) > 1 and token[0] == "." and token[1].isalpha(): if len(token) > 1 and token[0] == "." and token[1].isalpha():
continue # a hidden file such as .clang-format continue # a dotfile, for example one of a linter
token = token.strip("().,;:!?") token = token.strip("().,;:!?")
# a token with code characters (quotes, braces, `=`, `/`, `_`, digits) is not a word # a token with code characters (quotes, braces, `=`, `/`, `_`, digits) is not a word
if not token or _NOT_WORD_CHARS.search(token) or "::" in token: if not token or _NOT_WORD_CHARS.search(token) or "::" in token:
@@ -225,14 +228,30 @@ def check_text(
) -> list[Finding]: ) -> list[Finding]:
"""Findings of one file; with ``lines`` only the blocks that touch those line numbers.""" """Findings of one file; with ``lines`` only the blocks that touch those line numbers."""
findings: list[Finding] = [] findings: list[Finding] = []
for first, last, body in blocks(extract(path, text)): for group in groups(extract(path, text)):
body = " ".join(line for _, line in group)
if IGNORE_MARKER.search(body): if IGNORE_MARKER.search(body):
continue continue
if lines is not None and not any(first <= n <= last for n in lines): found: list[Finding] = []
continue if len(group) > 1:
verdict = judge(body, allowed, threshold, min_words) # each line on its own, so an English line cannot hide a Danish one next to it
if verdict: found = [
findings.append(Finding(path, first, last, verdict[0], round(verdict[1], 2), body)) Finding(path, number, number, verdict[0], round(verdict[1], 2), line)
for number, line in group
if (
verdict := judge(
line, allowed, max(threshold, LINE_THRESHOLD), max(min_words, LINE_MIN_WORDS)
)
)
]
if not found and (verdict := judge(body, allowed, threshold, min_words)):
# the block as a whole, so a sentence cut over short lines is not missed
found = [Finding(path, group[0][0], group[-1][0], verdict[0], round(verdict[1], 2), body)]
findings += [
f
for f in found
if lines is None or any(f.line <= n <= f.end_line for n in lines)
]
return findings return findings