From acd0bd64d60d3d8fd681a1be6289f5f51081eb60 Mon Sep 17 00:00:00 2001 From: Koji Shimba Date: Thu, 4 Jun 2026 17:36:49 +0000 Subject: [PATCH] Add blank-line check (condition 4) to the comment subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In -base mode, koryluslint comment now verifies the blank comment line between the English block and the [Ja] marker: a multi-line English block requires exactly one blank line right above the marker (4a), while a one-line English comment must not have one (4b). To keep false positives near zero, the check skips comment groups containing lines it cannot classify as English, Japanese, or blank (separators, code examples, URL-only lines) and groups with duplicate markers, which condition 3 already reports. [Ja] -base モードの koryluslint comment で、英語ブロックと [Ja] マーカーの 間の空行 (コメント記号のみの行) を検査するようにした。英語ブロックが 複数行のときはマーカー直前に空行が 1 行必要 (4a)、1 行のときは空行を 置いてはならない (4b)。誤検出をほぼゼロに保つため、英文・日本語・空行の いずれにも分類できない行 (区切り線・コード例・URL のみの行) を含む コメント群と、条件 3 で報告済みのマーカー重複群ではこの検査をスキップする。 --- internal/comment/comment.go | 163 ++++++++++++++++++++++++++----- internal/comment/comment_test.go | 113 +++++++++++++++++++-- 2 files changed, 247 insertions(+), 29 deletions(-) diff --git a/internal/comment/comment.go b/internal/comment/comment.go index 92f79e5..bd5e513 100644 --- a/internal/comment/comment.go +++ b/internal/comment/comment.go @@ -1,16 +1,22 @@ // Package comment implements the comment subcommand of koryluslint, which // checks that bilingual code comments use the [Ja] marker correctly. The // marker introduces the Japanese translation block, which must sit below a -// corresponding English block and appear at most once per comment group. The -// check targets the recurring *misuse* of the marker rather than enforcing -// full bilingual coverage, which keeps false positives near zero. +// corresponding English block and appear at most once per comment group. +// When the English block spans multiple lines, one blank comment line must +// separate it from the marker; when it is a single line, no blank line is +// allowed. The check targets the recurring *misuse* of the marker rather than +// enforcing full bilingual coverage, which keeps false positives near zero; +// for the same reason the blank-line check skips comment groups that contain +// lines it cannot classify as English or Japanese (separators, code examples, +// URL-only lines, and the like). // // Two modes: // // comment [paths...] checks the [Ja]-on-non-Japanese rule across // the whole tree (default: "."). -// comment -base= [paths...] also checks the marker-placement rules, -// limited to lines added since . +// comment -base= [paths...] also checks the marker-placement and +// blank-line rules, limited to lines added +// since . // // .go files are parsed via go/parser so that "//" inside string literals is // never mistaken for a comment; .templ files (not valid Go) are scanned by line. @@ -18,15 +24,18 @@ // [Ja] comment パッケージは koryluslint の comment サブコマンドを実装し、 // コードコメントの英日併記で `[Ja]` マーカーが正しく使われているかをチェックする。 // `[Ja]` は日本語訳ブロックの冒頭を示すマーカーで、対応する英語ブロックの下に置き、 -// 1 コメント群に 1 つだけ付ける。全併記の強制ではなく、再発している「マーカーの誤用」 -// のみを対象にすることで誤検出をほぼゼロに保つ。 +// 1 コメント群に 1 つだけ付ける。英語ブロックが複数行のときはマーカーとの間に +// 空行 (コメント記号のみの行) を 1 行置き、1 行のときは空行を置かない。 +// 全併記の強制ではなく、再発している「マーカーの誤用」のみを対象にすることで +// 誤検出をほぼゼロに保つ。同じ理由で、空行の検査は英語とも日本語とも判定できない行 +// (区切り線・コード例・URL のみの行など) を含むコメント群をスキップする。 // // モードは 2 つ: // // comment [paths...] 「[Ja] が日本語を含まない行に付く」誤用を // ツリー全体で検査する (既定は ".")。 -// comment -base= [paths...] マーカー配置の規則も検査する。 以降に -// 追加された行に限定する。 +// comment -base= [paths...] マーカー配置・空行の規則も検査する。 +// 以降に追加された行に限定する。 // // .go は go/parser で解析し、文字列リテラル中の "//" をコメントと誤認しない。 // .templ (Go として不正) は行単位で走査する。 @@ -63,6 +72,9 @@ var ( // reGenerated matches the standard "generated; do not edit" header. // [Ja] reGenerated は「生成物・編集禁止」の定型ヘッダーにマッチする。 reGenerated = regexp.MustCompile(`^//\s*Code generated .* DO NOT EDIT\.$`) + // reURLOnly matches a line whose whole content is a single URL. + // [Ja] reURLOnly は本文全体が 1 つの URL である行にマッチする。 + reURLOnly = regexp.MustCompile(`^https?://\S+$`) ) // commentLine is a single physical line of a comment with its 1-based line number. @@ -81,10 +93,27 @@ type finding struct { msg string } +// lineKind classifies a non-marker comment line for the blank-line check +// (condition 4). +// +// [Ja] lineKind は空行検査 (条件 4) のために非マーカー行を分類した種別。 +type lineKind int + +const ( + kindNone lineKind = iota // no line seen yet. [Ja] まだ行が無い + kindBlank // comment leader only, empty content. [Ja] コメント記号のみで本文が空 + kindEnglish // English text. [Ja] 英文 + kindJapanese // Japanese text. [Ja] 日本語文 + kindOther // neither English nor Japanese (separator, code, URL). [Ja] 英文とも日本語とも判定できない + kindMarker // a [Ja] marker line. [Ja] マーカー行 +) + const ( - msgCond1 = "[Ja] marker on a line with no Japanese text; it must lead the Japanese translation, not the English block / [Ja] マーカーが日本語を含まない行に付いている" - msgCond2 = "[Ja] marker has no English block above it; write the English block first, then [Ja] / [Ja] の上に対応する英語ブロックが無い" - msgCond3 = "[Ja] marker appears more than once in one comment block; mark only the first line of the Japanese block / [Ja] マーカーが 1 コメント群に複数ある" + msgCond1 = "[Ja] marker on a line with no Japanese text; it must lead the Japanese translation, not the English block / [Ja] マーカーが日本語を含まない行に付いている" + msgCond2 = "[Ja] marker has no English block above it; write the English block first, then [Ja] / [Ja] の上に対応する英語ブロックが無い" + msgCond3 = "[Ja] marker appears more than once in one comment block; mark only the first line of the Japanese block / [Ja] マーカーが 1 コメント群に複数ある" + msgCond4a = "[Ja] marker after a multi-line English block needs one blank comment line right above it / [Ja] 英語ブロックが複数行のときはマーカーの直前に空行が必要" + msgCond4b = "[Ja] marker after a one-line English comment must not have a blank line above it / [Ja] 英語ブロックが 1 行のときはマーカーの直前に空行を入れない" ) // Run is the entry point of the comment subcommand. args is what remains after @@ -213,23 +242,50 @@ func collectFindings(roots []string, base string, stderr io.Writer) ([]finding, // Only lines whose comment content *begins* with the [Ja] marker count as // marker lines; a mid-sentence mention of "[Ja]" in English prose (such as this // tool's own docs) is ignored. At most one finding per marker line is produced -// (condition 1 supersedes condition 2 on the same line); condition 3 is reported -// independently. +// (on the same line condition 1 supersedes condition 2, which supersedes +// condition 4); condition 3 is reported independently. Condition 4 (the blank +// line between the English block and the marker) is evaluated only while every +// line above the marker classifies as English, Japanese, or blank; one +// unclassifiable line (separator, code example, URL-only) disables it for the +// rest of the group, and so does a second marker (condition 3 already reports +// the broken structure, where any blank-line judgement would be a guess). // // [Ja] checkGroup は 1 コメント群を [Ja] マーカー規則で評価する。 // コメント本文が [Ja] マーカーで「始まる」行だけをマーカー行とみなし、英語の地の文中で // "[Ja]" に言及しているだけの行 (本ツール自身の説明など) は無視する。1 マーカー行あたり -// 最大 1 件 (同じ行では条件 1 が条件 2 に優先する)。条件 3 は独立に報告する。 +// 最大 1 件 (同じ行では条件 1 が条件 2 に、条件 2 が条件 4 に優先する)。条件 3 は独立に +// 報告する。条件 4 (英語ブロックとマーカーの間の空行) は、マーカーより上の全行が +// 英文・日本語・空行のいずれかに分類できる間だけ評価し、分類できない行 (区切り線・ +// コード例・URL のみの行) が 1 つでもあれば群の残りでは無効にする。2 つ目以降の +// マーカーでも同様に無効にする (壊れた構造は条件 3 で報告済みで、そこへの空行判定は +// 当て推量になるため)。 func checkGroup(lines []commentLine) []finding { var fs []finding markerCount := 0 englishSeenAbove := false + // State for condition 4: the count of English lines above the marker, the + // kind of the previous line, and whether an unclassifiable line was seen. + // + // [Ja] 条件 4 のための状態: マーカーより上の英文行の数、直前行の種別、 + // 分類できない行が出現したかどうか。 + englishAbove := 0 + prevKind := kindNone + unclassifiable := false + for _, cl := range lines { if !markerAtStart(cl.text) { if isEnglishText(cl.text) { englishSeenAbove = true } + kind := classifyLine(cl.text) + switch kind { + case kindEnglish: + englishAbove++ + case kindOther: + unclassifiable = true + } + prevKind = kind continue } @@ -238,22 +294,85 @@ func checkGroup(lines []commentLine) []finding { fs = append(fs, finding{line: cl.line, cond: 3, msg: msgCond3}) } - if !hasJapanese(cl.text) { + switch { + case !hasJapanese(cl.text): // Condition 1: the marker leads an English (or marker-only) line. // [Ja] 条件 1: マーカーが英語 (またはマーカーのみ) の行を先導している。 fs = append(fs, finding{line: cl.line, cond: 1, msg: msgCond1}) - continue - } - - // Condition 2: a marker line still needs an English block above it. - // [Ja] 条件 2: マーカー行でも、その上に英語ブロックが必要。 - if !englishSeenAbove { + case !englishSeenAbove: + // Condition 2: a marker line still needs an English block above it. + // [Ja] 条件 2: マーカー行でも、その上に英語ブロックが必要。 fs = append(fs, finding{line: cl.line, cond: 2, msg: msgCond2}) + case unclassifiable || markerCount > 1: + // Skip condition 4: a line above defies classification, or the + // group has more than one marker (already reported as condition + // 3), so the blank-line judgement would be a guess. Prefer a + // false negative. + // + // [Ja] 条件 4 をスキップ: 上の行に分類できない行があるか、群に + // マーカーが複数あり (条件 3 で報告済み)、空行の判定が当て推量に + // なるため。偽陰性側に倒す。 + case prevKind == kindEnglish && englishAbove >= 2: + // Condition 4 (a): a multi-line English block runs straight into + // the marker without the separating blank line. + // + // [Ja] 条件 4 (a): 複数行の英語ブロックが空行を挟まずマーカーに + // 連続している。 + fs = append(fs, finding{line: cl.line, cond: 4, msg: msgCond4a}) + case prevKind == kindBlank && englishAbove == 1: + // Condition 4 (b): a one-line English comment is separated from + // the marker by a blank line it must not have. + // + // [Ja] 条件 4 (b): 1 行の英語コメントとマーカーの間に不要な空行が + // 入っている。 + fs = append(fs, finding{line: cl.line, cond: 4, msg: msgCond4b}) } + prevKind = kindMarker } return fs } +// classifyLine classifies one non-marker comment line for the blank-line +// check (condition 4). The comment leader is stripped first — "//" or "/*" +// once, then at most one "*" continuation, so that "/**" classifies as blank +// while a row of stars ("//****") keeps its content and classifies as +// kindOther. Content indented with a tab or with two or more spaces after the +// leader is a code example (prose keeps a single space), and a line whose +// whole content is a URL is not prose, so both classify as kindOther. +// +// [Ja] classifyLine は空行検査 (条件 4) のために非マーカー行を 1 行分類する。 +// 先にコメントリーダーを取り除く。"//" または "/*" を 1 回、続けて継続記号の +// "*" を高々 1 つだけ除去することで、"/**" は空行扱いにしつつ、星の並び +// ("//****") は本文が残って kindOther に分類される。リーダー直後がタブまたは +// スペース 2 つ以上で字下げされた本文はコード例 (地の文はスペース 1 つ)、 +// 本文全体が URL の行は地の文ではないため、いずれも kindOther に分類する。 +func classifyLine(text string) lineKind { + s := strings.TrimSpace(text) + for _, leader := range []string{"//", "/*"} { + if strings.HasPrefix(s, leader) { + s = strings.TrimPrefix(s, leader) + break + } + } + s = strings.TrimPrefix(s, "*") // at most one "*" continuation. [Ja] 継続記号の "*" は高々 1 つ + if strings.HasPrefix(s, "\t") || strings.HasPrefix(s, " ") { + return kindOther + } + s = strings.TrimSpace(s) + switch { + case s == "": + return kindBlank + case reURLOnly.MatchString(s): + return kindOther + case hasJapanese(s): + return kindJapanese + case isEnglishText(s): + return kindEnglish + default: + return kindOther + } +} + // markerAtStart reports whether the comment content begins with the [Ja] marker, // after stripping the comment leader ("//", "/*", or a "*" continuation) and // surrounding whitespace. This distinguishes a marker use from a prose mention. diff --git a/internal/comment/comment_test.go b/internal/comment/comment_test.go index 66c373f..2dfea43 100644 --- a/internal/comment/comment_test.go +++ b/internal/comment/comment_test.go @@ -45,8 +45,10 @@ func TestCheckGroup(t *testing.T) { wantConds []int }{ { - name: "correct multi-line block", - lines: group("// New post form.", "//", "// [Ja] 新規投稿フォーム。"), + name: "correct multi-line block", + // Two English lines, then a blank comment line, then the marker. + // [Ja] 英文 2 行 → 空行 → マーカーの正しい形。 + lines: group("// New post form.", "// It renders the editor.", "//", "// [Ja] 新規投稿フォーム。", "// エディタを表示する。"), wantConds: nil, }, { @@ -54,6 +56,13 @@ func TestCheckGroup(t *testing.T) { lines: group("// Hash the password.", "// [Ja] パスワードをハッシュ化する。"), wantConds: nil, }, + { + name: "correct multi-paragraph English block", + // English paragraphs count as one block across the paragraph break. + // [Ja] 段落区切りをまたいでも英語ブロックは 1 つとして数える。 + lines: group("// First paragraph.", "//", "// Second paragraph here.", "//", "// [Ja] 最初の段落。", "//", "// 2 つ目の段落。"), + wantConds: nil, + }, { name: "correct inline pair on a single line", lines: group("// Hash the password. [Ja] パスワードをハッシュ化する。"), @@ -104,6 +113,62 @@ func TestCheckGroup(t *testing.T) { lines: group("// English.", "// [Ja] 日本語。", "// [Ja] second english marker."), wantConds: []int{3, 1}, }, + { + name: "missing blank line after a multi-line English block (4a)", + // Two English lines run straight into the marker (the §2.1.2 violation). + // [Ja] 英文 2 行が空行なしでマーカーに連続している (§2.1.2 違反)。 + lines: group("// Render the page title.", "// The site default is appended.", "// [Ja] ページタイトルをレンダリングする。"), + wantConds: []int{4}, + }, + { + name: "unnecessary blank line after a one-line English comment (4b)", + // A blank line follows a one-line English comment (the §2.1.5 bad example). + // [Ja] 英文 1 行なのに空行を挟んでいる (§2.1.5 の悪い例)。 + lines: group("// Hash the password.", "//", "// [Ja] パスワードをハッシュ化する。"), + wantConds: []int{4}, + }, + { + name: "code example above the marker skips condition 4", + // The tab-indented code line is unclassifiable, so the missing blank line is not reported. + // [Ja] タブ字下げのコード行は分類できないため、空行欠落を報告しない。 + lines: group("//\tkoryluslint comment .", "//", "// Run the tool first.", "// Then check the output.", "// [Ja] 先にツールを実行し、出力を確認する。"), + wantConds: nil, + }, + { + name: "space-indented code example above the marker skips condition 4", + // The space-indented code line is unclassifiable, so the missing blank line is not reported. + // [Ja] スペース字下げのコード行は分類できないため、空行欠落を報告しない。 + lines: group("// Run the tool:", "// koryluslint comment .", "// [Ja] ツールを実行する。"), + wantConds: nil, + }, + { + name: "separator line above the marker skips condition 4", + // The dash separator is unclassifiable, so the missing blank line is not reported. + // [Ja] ダッシュの区切り線は分類できないため、空行欠落を報告しない。 + lines: group("// ----", "// Run the tool first.", "// Then check the output.", "// [Ja] 先にツールを実行し、出力を確認する。"), + wantConds: nil, + }, + { + name: "star-row separator above the marker skips condition 4", + // A row of stars is unclassifiable (not a blank line), so condition 4 stays silent. + // [Ja] 星のみの区切り線は空行ではなく分類できない行のため、条件 4 を報告しない。 + lines: group("// Hash the password.", "//****", "// [Ja] パスワードをハッシュ化する。"), + wantConds: nil, + }, + { + name: "second marker in a duplicate-marker group does not add condition 4", + // The duplicate marker is already condition 3; the blank-line check stays silent for it. + // [Ja] 重複マーカーは条件 3 で報告済みのため、空行検査は発火しない。 + lines: group("// First pair.", "// [Ja] 最初のペア。", "// Second pair English.", "// [Ja] 二つ目のペア。"), + wantConds: []int{3}, + }, + { + name: "URL-only line above the marker skips condition 4", + // A URL-only line is not prose, so condition 4 stays silent for the group. + // [Ja] URL のみの行は地の文ではないため、この群では条件 4 を報告しない。 + lines: group("// See the upstream issue.", "// https://github.com/golang/go/issues/12345", "// [Ja] 上流の issue を参照。"), + wantConds: nil, + }, } for _, tt := range tests { @@ -154,6 +219,34 @@ func TestIsEnglishText(t *testing.T) { } } +func TestClassifyLine(t *testing.T) { + t.Parallel() + + tests := []struct { + text string + want lineKind + }{ + {"// Set the CSRF token on the context.", kindEnglish}, + {"// CSRF トークンを設定する。", kindJapanese}, + {"//", kindBlank}, + {"// ", kindBlank}, + {"/**", kindBlank}, // block-comment opener has no content. [Ja] ブロックコメントの開始行は本文なし + {"\t* Renders the page title.", kindEnglish}, + {"//\tkoryluslint comment .", kindOther}, // godoc-style code block. [Ja] godoc 形式のコード例 + {"// koryluslint comment .", kindOther}, // space-indented code example. [Ja] スペース字下げのコード例 + {"// https://example.com/issues/1", kindOther}, + {"// ----", kindOther}, + {"//****", kindOther}, // a star row keeps its content, unlike "/**". [Ja] 星の並びは "/**" と違い本文が残る + {"*****", kindOther}, // a bare star row inside a block comment. [Ja] ブロックコメント内の星のみの区切り線 + {"// See https://example.com for details.", kindEnglish}, // a URL inside prose stays English. [Ja] 地の文中の URL は英文のまま + } + for _, tt := range tests { + if got := classifyLine(tt.text); got != tt.want { + t.Errorf("classifyLine(%q) = %v, want %v", tt.text, got, tt.want) + } + } +} + func TestGoCommentGroupsIgnoresStringLiterals(t *testing.T) { t.Parallel() @@ -280,13 +373,14 @@ func TestRunFullMode(t *testing.T) { } } -// TestRunNoViolations confirms a clean tree exits 0 with no output. Condition 2 -// (no English block above) is not reported in full mode, so a Japanese-only -// comment must not trip the check. +// TestRunNoViolations confirms a clean tree exits 0 with no output. Conditions +// 2 (no English block above) and 4 (blank line before the marker) are not +// reported in full mode, so neither a Japanese-only comment nor a missing +// blank line must trip the check. // // [Ja] TestRunNoViolations は問題のないツリーが無出力・終了コード 0 になることを -// 確認する。全体モードでは条件 2 (英語ブロック無し) を報告しないため、日本語のみの -// コメントで検査が落ちてはならない。 +// 確認する。全体モードでは条件 2 (英語ブロック無し) と条件 4 (マーカー前の空行) を +// 報告しないため、日本語のみのコメントや空行欠落で検査が落ちてはならない。 func TestRunNoViolations(t *testing.T) { t.Parallel() @@ -300,6 +394,11 @@ func TestRunNoViolations(t *testing.T) { "", "// [Ja] 日本語のみのコメント。", "func JapaneseOnly() {}", + "", + "// Wave waves at the user.", + "// It never returns an error.", + "// [Ja] Wave はユーザーに手を振る (空行欠落だが全体モードでは報告されない)。", + "func Wave() {}", }, "\n")) var stdout, stderr bytes.Buffer