diff --git a/LESSONS.md b/LESSONS.md
index a88e245..b9915ea 100644
--- a/LESSONS.md
+++ b/LESSONS.md
@@ -468,3 +468,33 @@ response = json.loads(proc.stdout.readline())
- 却下した案: skill の改名・日本語化を新規の書き起こしとして扱い、レビューは新しい文面が読みやすく自然かだけを見る。
- 決め手: test-runner の skill を日本語化したコミットで、旧版にあった注意書き5件(`no_tests` はテストが通った証拠にならない/件数には親テストとサブテストの両方が入る/error・timeout・cancelled を成功扱いしない/自動リトライしない/テスト範囲はむやみに広げない)が消えていた。さらに「全件スキップは `no_tests`」という実装と逆の記述が入った。実装(`events.go`)では件数がすべて 0 のときだけ `no_tests` で、全件スキップは `passed` になる。README は正しかったので、食い違いは skill だけで起きていた。旧版と新版を並べて差分を取り、ステータスの定義をコードの分岐と突き合わせたことで見つかった。
- 覆す条件: skill の記述を実装や README から生成・検証する仕組み(CI での照合など)が入り、手作業の書き換えで意味が変わらなくなった場合。
+
+## 2026-09-20: `.go` の判定はパターンの綴りではなく `go list` の解決結果で行う
+
+- 却下した案: パッケージパターン末尾の `.go` を禁止して `.go` ファイル一覧を弾く(test-runner 初版の実装)。
+- 決め手: `go list -e -json` の実測で、`pkg.go` という名前のディレクトリは import path `example.com/exp/pkg.go` として解決され、ファイル一覧は `command-line-arguments`(`Module` 欠落)として解決される。綴り判定は正当なパッケージを拒否するうえ、失敗時に自分が生成した `rerun`(`packages` に解決済み import path を入れる)を再受理できず round-trip が壊れる。解決済み import path なら `go test example.com/exp/pkg.go` はファイル一覧と解釈されず正常実行される(ファイル一覧モードは実在ファイルが条件)。混在指定・不存在パスも `Error` 付きレコードとして解決結果で弾ける。
+- 覆す条件: `go list` がファイル一覧を `command-line-arguments` 以外で表すようになった場合。
+
+## 2026-09-20: 呼び出し側の不正な `-run` 式はツール実行前に入力エラーとして弾く
+
+- 却下した案: 不正な式をそのまま `go test` に渡す(初版)か、出力の `testing: invalid regexp` 文字列を検出して事後判定する。
+- 決め手: 実測で `-run '['` は全パッケージに package 単位の `fail` イベント+exit 1 を出し、失敗テスト0件のまま `status: failed`・MCP `isError: false` になる=入力エラーがプロジェクトのテスト失敗に化ける。固定 Go の `src/testing/match.go` の `splitRegexp` と同じ分割(`[]`・`()`・`\` を尊重)+空白→`_` 置換で各要素を `regexp.Compile` すると、実 `go test -run` と22ケースで完全一致した。`\` 分岐を外した版と実測比較したところ、差が出るのは呼び出し側が渡す `a\/b`・`a\|b`(エスケープされた区切り)で、分割が `a\` を作って `regexp.Compile` が落ち、go が受理する式を誤拒否する。`exactRun` の出力は `/` 区切りで連結した QuoteMeta 済み要素なので、分割が崩れて要素が結合しても必ずコンパイルでき、この分岐の有無に影響されない(当初「自分の rerun を誤拒否する」と推定したが実測で否定)。空白置換を写さないと `(?Px)` を誤って拒否する(go 側は `rewrite` で有効化する)。非印字文字のエスケープは正規表現の妥当性を変えないため写していない。
+- 覆す条件: `testing` 側の分割・`rewrite` 仕様が変わり一致が崩れた場合(固定 Go の `src/testing/match.go` を再 diff して判断する)。
+
+## 2026-09-20: `testing.rewrite` は空白置換だけでなく非印字エスケープまで写す(直前の教訓を覆す)
+
+- 却下した案: 非印字文字のエスケープは正規表現の妥当性を変えないとして写さない(直前エントリの結論)。および、写す際に空白判定を `unicode.IsSpace` で代用する。
+- 決め手: 実 `go test -run` で双方向にズレを観測した。U+200B 単体は `invalid regexp for element 0 of -test.run (""): invalid escape sequence` で失敗する(=旧実装が受理して入力エラーがテスト失敗に化ける、この機能が防ぐはずだった当の症状)。逆に `\`+U+200B は `ok` で通る(=旧実装が誤拒否)。妥当性を変えないという前提が誤りだった。空白判定を代用しない理由は別。全ルーン走査(U+0000–U+10FFFF)で `testing.isSpace` と `unicode.IsSpace` の相違は0件、固定 1.26.8 と 1.27.1 の `isSpace` も同一だが、`testing` 側は「not the same as Unicode Z class」と明記して手書きスイッチにしている=Unicode テーブル由来の判定は `White_Space` が追加された時点で静かにズレ、この写しが防ごうとしている乖離そのものを再発させる。期待値は手で書かず、`-run` に `[`+対象ルーンを渡すと要素が必ず不正になりエラーが書き換え後の文字列をそのまま出力する性質を使って実測した。
+- 覆す条件: `testing` 側の `rewrite`/`isSpace` が変わった場合(固定 Go の `src/testing/match.go` を再 diff)。また、ミラーの手写しが破綻するなら、実 `go test -run` の出力を期待値として生成する照合を CI に入れる方向へ切り替える。
+
+## 2026-09-20: 予約 import path の判定は綴りではなく `Module` の有無で行う
+
+- 却下した案: import path が `command-line-arguments` なら無条件で拒否する。
+- 決め手: `module command-line-arguments` を宣言したモジュールは実在でき、`go list -json ./...` が同じ import path を **`Module` 付き**で返し `go test command-line-arguments` は `ok` になる。一方 `.go` ファイル一覧指定では `Module: null` で返る。綴りだけで弾くと正当なモジュールを拒否する。直前の教訓「`.go` の判定は `go list` の解決結果で行う」と同じ誤りが、パターン側からセンチネル側へ移っていただけだった。判定は既に module framing 欠落を扱っている分岐へ移した。
+- 覆す条件: `go list` がファイル一覧に `Module` を付けて返すようになった場合。
+
+## 2026-09-20: ミラーの忠実さは「どの Go で動くか」で決まる——`strconv.IsPrint` は写さず絞る
+
+- 却下した案: `testing.rewrite` を完全同一に写す(`!strconv.IsPrint(r)` をそのまま使う)。レビュー指摘の字面どおりの実装。
+- 決め手: `rewrite` はユーザーのツールチェーン内で動き、こちらのミラーは固定 1.26.8 でビルドされたバイナリ内で動く。`strconv.IsPrint` は Unicode バージョンごとに生成されるテーブルで、全ルーン走査の実測で **1.27.1 では印字可・1.26.8 では不可が 10,615 ルーン**あった。実害も再現済みで、U+088F を `-run` に渡すと 1.27.1 の `go test` は `ok`、1.26.8 は `` にエスケープして拒否する。完全同一に写すと、Go 1.27 系ユーザーに対して有効な式を——`exactRun` が報告済みテスト名から自分で組み立てた rerun ごと——誤拒否する。`unicode.IsGraphic` は同じテーブル由来で連動する(1.26.8 で該当0件/1.27.1 で全件)ため代替軸にならない。ドリフトした 10,615 ルーンは 1.26.8 では**全て `Cn`(未割当)**で、逆に U+200B(Cf)・U+0007(Cc)・U+FEFF(Cf)・U+E000(Co) は安定側。よってエスケープを Cc/Cf/Cs/Co に限定した。取りこぼしは「素通しして受理」=修正前の挙動に戻るだけで、入力エラーの誤報にはならない安全側に倒れる。空白判定側は逆に手写しが正しい(`testing.isSpace` は固定スイッチ)ため、2つの分岐で判断が分かれる。
+- 覆す条件: `rewrite` の仕様自体が変わった場合。なお直前エントリの「固定 Go の `src/testing/match.go` を再 diff」は不正確で、`-run` を書き換えるのは**呼び出し側のツールチェーン**なので、固定版だけでなく対応したい最小・最大の Go 版で突き合わせる。あるいは実 `go test -run` の出力から期待値を生成する照合を CI に入れる。
diff --git a/test-runner/README.md b/test-runner/README.md
index 198c20a..33595ae 100644
--- a/test-runner/README.md
+++ b/test-runner/README.md
@@ -16,8 +16,8 @@ agy plugin install https://github.com/kwrkb/agy-plugins/test-runner
| Argument | Meaning |
| :-- | :-- |
| `module_path` | Required directory containing `go.mod`. Prefer an absolute path: relative paths use the MCP server's working directory. |
-| `packages` | Nonempty string array of Go package patterns; default `["./..."]`. Local paths and import paths are accepted only when all resolved packages belong to the selected module and are physically inside it. |
-| `run` | Optional Go `-run` expression, including slash-separated subtest expressions. |
+| `packages` | Nonempty string array of Go package patterns; default `["./..."]`. Local paths and import paths are accepted only when all resolved packages belong to the selected module and are physically inside it. A package directory or import path may end in `.go`; a `.go` file list is rejected by its discovery result. |
+| `run` | Optional Go `-run` expression, including slash-separated subtest expressions. An expression `go test` would reject at startup is reported as an input error instead of a test failure. |
| `timeout_seconds` | Integer 1–300, default 60. Covers discovery, compilation, and tests. Process cleanup can take a few additional seconds. |
Example arguments:
@@ -46,7 +46,7 @@ Each MCP result contains a JSON text object:
Use a failure's `rerun` object as the next `go_test` arguments. It retains the absolute module path and timeout, selects one package, and anchors/escapes each subtest-name segment. Go still runs parent setup to discover a selected subtest. Parent failures can overlap child failures; choose the parent to rerun a group or the child to narrow the retry. There is no server-side retry state.
-Malformed/oversized JSON events (over 1 MiB), over 100,000 result records, and discovery output over 8 MiB produce explicit execution errors. Output continues to drain after a parsing error to avoid blocking the child; the overall timeout still applies. Timeout/cancellation stops the Unix process group or the Windows process tree and bounds pipe shutdown. Programs that deliberately detach from the process tree are outside this mechanism.
+A `run` expression over 64 KiB is rejected as an input error, because it is validated before the timeout starts. Malformed/oversized JSON events (over 1 MiB), over 100,000 result records, and discovery output over 8 MiB produce explicit execution errors. Output continues to drain after a parsing error to avoid blocking the child; the overall timeout still applies. Timeout/cancellation stops the Unix process group or the Windows process tree and bounds pipe shutdown. Programs that deliberately detach from the process tree are outside this mechanism.
## Development and validation
diff --git a/test-runner/README_ja.md b/test-runner/README_ja.md
index eb2a488..cda00ce 100644
--- a/test-runner/README_ja.md
+++ b/test-runner/README_ja.md
@@ -16,8 +16,8 @@ agy plugin install https://github.com/kwrkb/agy-plugins/test-runner
| 引数 | 内容 |
| :-- | :-- |
| `module_path` | 必須。`go.mod` があるディレクトリ。相対パスはMCPサーバーの作業ディレクトリ基準なので、絶対パスを推奨します。 |
-| `packages` | 空でない文字列配列。既定 `["./..."]`。ローカルパス・import pathを受け付け、解決した全パッケージが対象モジュールに所属し、そのディレクトリ内にあることを確認します。 |
-| `run` | 任意。Goの `-run` 正規表現。`/` 区切りのサブテスト指定にも対応します。 |
+| `packages` | 空でない文字列配列。既定 `["./..."]`。ローカルパス・import pathを受け付け、解決した全パッケージが対象モジュールに所属し、そのディレクトリ内にあることを確認します。`.go` で終わるパッケージディレクトリ・import pathも指定でき、`.go` ファイル一覧は解決結果で拒否します。 |
+| `run` | 任意。Goの `-run` 正規表現。`/` 区切りのサブテスト指定にも対応します。`go test` が起動時に拒否する式は、テスト失敗ではなく入力エラーとして返します。 |
| `timeout_seconds` | 1~300の整数。既定60秒。探索・ビルド・テスト全体の制限です。停止処理には追加で数秒かかる場合があります。 |
引数例:
diff --git a/test-runner/bin/test-runner-darwin-arm64 b/test-runner/bin/test-runner-darwin-arm64
index 7932da2..19b14f8 100755
Binary files a/test-runner/bin/test-runner-darwin-arm64 and b/test-runner/bin/test-runner-darwin-arm64 differ
diff --git a/test-runner/bin/test-runner-linux-amd64 b/test-runner/bin/test-runner-linux-amd64
index 4cbb3e8..4604fc0 100755
Binary files a/test-runner/bin/test-runner-linux-amd64 and b/test-runner/bin/test-runner-linux-amd64 differ
diff --git a/test-runner/bin/test-runner.exe b/test-runner/bin/test-runner.exe
index fee5531..098ce09 100755
Binary files a/test-runner/bin/test-runner.exe and b/test-runner/bin/test-runner.exe differ
diff --git a/test-runner/skills/test-runner-gemini/SKILL.md b/test-runner/skills/test-runner-gemini/SKILL.md
index bd1326d..2b4d328 100644
--- a/test-runner/skills/test-runner-gemini/SKILL.md
+++ b/test-runner/skills/test-runner-gemini/SKILL.md
@@ -12,8 +12,8 @@ description: go_test MCP ツールを使用して Go テストを実行し、失
`go_test` を呼び出す際は、`go.mod` が存在するディレクトリの絶対パスを `module_path` に指定します。MCP サーバーのカレントワーキングディレクトリはリポジトリルートと異なる場合があるため、相対パスではなく絶対パスを推奨します。
- **`module_path`** (string, 必須): `go.mod` が置かれているディレクトリの絶対パス。マルチモジュール構成のリポジトリでは、対象モジュールごとに個別に呼び出します。
-- **`packages`** (array of string, 任意): パッケージパターン。既定値は `["./..."]`。フラグや `.go` ファイル一覧の指定は不可。
-- **`run`** (string, 任意): Go の `-run` 正規表現(サブテストの `/` 区切り構文に対応)。
+- **`packages`** (array of string, 任意): パッケージパターン。既定値は `["./..."]`。フラグの指定は不可。`.go` ファイル一覧も不可だが、`.go` で終わるパッケージディレクトリ・import path は指定できる。
+- **`run`** (string, 任意): Go の `-run` 正規表現(サブテストの `/` 区切り構文に対応)。`go test` が起動時に拒否する不正な式は、テスト失敗ではなく `status: "error"` として返ります。64 KiB を超える式も同様に入力エラーになります。
- **`timeout_seconds`** (integer, 任意): パッケージ探索・コンパイル・テスト実行全体の上限時間(1〜300秒、既定60秒)。
## 実行結果の確認ポイント
@@ -24,7 +24,7 @@ description: go_test MCP ツールを使用して Go テストを実行し、失
- `passed`: 失敗なし(全件スキップの場合も含む。下記 `tests` を参照)
- `failed`: テスト失敗またはビルド失敗(MCP ツールとしては正常終了扱い)
- `no_tests`: テストの終了イベントが 0 件(実行対象のテストが無かった)。**関連テストが通った証拠にはならない**
- - `error` / `timeout` / `cancelled`: ツールエラー、タイムアウト、キャンセル中断(MCP の `isError` が true)
+ - `error` / `timeout` / `cancelled`: ツールエラー(不正な引数を含む)、タイムアウト、キャンセル中断(MCP の `isError` が true)
これらをテスト成功として報告してはいけません。
2. **`tests`**:
- `passed`・`failed`・`skipped` の件数。**親テストとサブテストの両方を含みます**。
diff --git a/test-runner/src/events.go b/test-runner/src/events.go
index e5ba964..6ce657d 100644
--- a/test-runner/src/events.go
+++ b/test-runner/src/events.go
@@ -6,7 +6,9 @@ import (
"fmt"
"regexp"
"sort"
+ "strconv"
"strings"
+ "unicode"
"unicode/utf8"
)
@@ -15,6 +17,13 @@ const returnedLogLimit = 64 << 10
const eventLimit = 1 << 20
const recordLimit = 100000
+// runLimit bounds the -run expression. validateRun compiles one regexp per
+// top-level element and runs before the request timeout context exists, so an
+// unbounded expression would spend that time outside the timeout and outside
+// elapsed_seconds. At this size validation measures in tens of milliseconds,
+// against roughly a second for a 2 MiB expression.
+const runLimit = 64 << 10
+
type Counts struct {
Passed int `json:"passed"`
Failed int `json:"failed"`
@@ -204,6 +213,130 @@ func (s *eventStream) consume(line []byte) {
}
}
func terminal(a string) bool { return a == "pass" || a == "fail" || a == "skip" }
+
+// splitRun mirrors testing.splitRegexp: go test cuts a -run expression into
+// elements at slashes and alternations that sit outside brackets, groups and
+// escapes, then compiles each element on its own. The escape case is load
+// bearing: without it an escaped separator such as `a\/b` splits into `a\`,
+// which does not compile, so a valid expression would be rejected.
+func splitRun(expr string) []string {
+ var elements []string
+ brackets, groups := 0, 0
+ for i := 0; i < len(expr); {
+ switch expr[i] {
+ case '[':
+ brackets++
+ case ']':
+ if brackets--; brackets < 0 { // An unmatched ']' is legal.
+ brackets = 0
+ }
+ case '(':
+ if brackets == 0 {
+ groups++
+ }
+ case ')':
+ if brackets == 0 {
+ groups--
+ }
+ case '\\':
+ i++
+ case '/', '|':
+ if brackets == 0 && groups == 0 {
+ elements = append(elements, expr[:i])
+ expr = expr[i+1:]
+ i = 0
+ continue
+ }
+ }
+ i++
+ }
+ return append(elements, expr)
+}
+
+// validateRun rejects what the test binary would reject at startup. Left to go
+// test, an invalid caller expression fails every package with no failing test,
+// which reads as a project test failure rather than an input error.
+func validateRun(expr string) error {
+ if expr == "" {
+ return nil
+ }
+ for i, element := range splitRun(expr) {
+ rewritten := rewriteRun(element)
+ if _, err := regexp.Compile(rewritten); err != nil {
+ shown := strconv.Quote(element)
+ if rewritten != element {
+ shown += fmt.Sprintf(" checked as %q", rewritten)
+ }
+ return fmt.Errorf("run element %d (%s) is not a valid expression: %w", i, shown, err)
+ }
+ }
+ return nil
+}
+
+// rewriteRun mirrors testing.rewrite, which go test applies to every -run
+// element before compiling it: whitespace collapses to '_' and non-printable
+// runes become their escaped spelling. Both substitutions decide whether an
+// element compiles, in either direction: a lone U+200B becomes the escape
+// "\u200b" that the regexp parser rejects, while a backslash before it becomes
+// an escaped backslash that parses.
+//
+// The escape branch deliberately narrows the mirror, because testing.rewrite
+// runs inside the toolchain under test while this runs inside ours, and
+// strconv.IsPrint is generated per Unicode version: 10615 runes are printable
+// under 1.27.1 and not under the pinned 1.26.8. Escaping those would reject
+// expressions the caller's go test accepts -- including reruns this server
+// builds itself from reported test names. Every one of them is unassigned
+// here, so the escape is limited to categories a later Unicode version cannot
+// turn printable. A rune this misses stays raw and is merely accepted, which
+// is the pre-existing behaviour, rather than wrongly reported as bad input.
+func rewriteRun(s string) string {
+ var b strings.Builder
+ for _, r := range s {
+ switch {
+ case isRunSpace(r):
+ b.WriteByte('_')
+ case !strconv.IsPrint(r) && stableNonPrint(r):
+ q := strconv.QuoteRune(r)
+ b.WriteString(q[1 : len(q)-1])
+ default:
+ b.WriteRune(r)
+ }
+ }
+ return b.String()
+}
+
+// stableNonPrint reports whether r is non-printable for a reason no Unicode
+// release can revise: a control, format, surrogate or private-use code point.
+// Unassigned code points are excluded precisely because assigning one is what
+// makes a newer toolchain print it raw.
+func stableNonPrint(r rune) bool {
+ return unicode.Is(unicode.Cc, r) || unicode.Is(unicode.Cf, r) ||
+ unicode.Is(unicode.Cs, r) || unicode.Is(unicode.Co, r)
+}
+
+// isRunSpace is testing.isSpace verbatim. unicode.IsSpace agrees with it on
+// every rune today, but it is a hand-written switch precisely because it is not
+// the Unicode Z class: deriving it from the Unicode tables instead would start
+// diverging the moment a release adds a White_Space rune, which is the same
+// mismatch this mirror exists to avoid.
+func isRunSpace(r rune) bool {
+ if r < 0x2000 {
+ switch r {
+ case '\t', '\n', '\v', '\f', '\r', ' ', 0x85, 0xA0, 0x1680:
+ return true
+ }
+ } else {
+ if r <= 0x200a {
+ return true
+ }
+ switch r {
+ case 0x2028, 0x2029, 0x202f, 0x205f, 0x3000:
+ return true
+ }
+ }
+ return false
+}
+
func exactRun(name string) string {
parts := strings.Split(name, "/")
for i, p := range parts {
diff --git a/test-runner/src/main.go b/test-runner/src/main.go
index c677854..da5b335 100644
--- a/test-runner/src/main.go
+++ b/test-runner/src/main.go
@@ -39,9 +39,11 @@ func parseOptions(args map[string]any) (Options, error) {
return o, fmt.Errorf("packages must be a nonempty string array")
}
}
+ // A directory or import path may legitimately end in ".go", so file lists are
+ // rejected by their discovery result instead of by this pattern's spelling.
for _, p := range o.Packages {
- if p == "" || strings.HasPrefix(p, "-") || strings.ContainsAny(p, "\x00\r\n") || strings.HasSuffix(p, ".go") {
- return o, fmt.Errorf("invalid package pattern %q (file lists and flags are not supported)", p)
+ if p == "" || strings.HasPrefix(p, "-") || strings.ContainsAny(p, "\x00\r\n") {
+ return o, fmt.Errorf("invalid package pattern %q (flags are not supported)", p)
}
}
if v, exists := args["run"]; exists {
@@ -49,6 +51,12 @@ func parseOptions(args map[string]any) (Options, error) {
if !ok || strings.ContainsRune(o.Run, 0) {
return o, fmt.Errorf("run must be a string without NUL bytes")
}
+ if len(o.Run) > runLimit {
+ return o, fmt.Errorf("run must be at most %d bytes", runLimit)
+ }
+ if err := validateRun(o.Run); err != nil {
+ return o, err
+ }
}
if v, exists := args["timeout_seconds"]; exists {
b, err := json.Marshal(v)
diff --git a/test-runner/src/main_test.go b/test-runner/src/main_test.go
index 40c3391..2447679 100644
--- a/test-runner/src/main_test.go
+++ b/test-runner/src/main_test.go
@@ -24,9 +24,10 @@ func TestOptions(t *testing.T) {
{"module_path": ".", "packages": []any{".", 2}},
{"module_path": ".", "packages": []string{}},
{"module_path": ".", "packages": []string{"-exec=evil"}},
- {"module_path": ".", "packages": []string{"a.go"}},
{"module_path": ".", "packages": nil},
{"module_path": ".", "run": false},
+ {"module_path": ".", "run": "["},
+ {"module_path": ".", "run": "A/("},
{"module_path": ".", "timeout_seconds": "60"},
{"module_path": ".", "timeout_seconds": 0},
{"module_path": ".", "timeout_seconds": 301},
@@ -44,6 +45,123 @@ func TestOptions(t *testing.T) {
t.Fatalf("%+v %v", o, err)
}
}
+ // A directory or import path may end in ".go"; only discovery can tell such a
+ // package from a file list, so the pattern itself is accepted here.
+ o, err := parseOptions(map[string]any{"module_path": ".", "packages": []string{"./pkg.go", "example.com/m/gen.go"}})
+ if err != nil || len(o.Packages) != 2 {
+ t.Fatalf("%+v %v", o, err)
+ }
+ // Expressions go test accepts, verified against a real "go test -run" run.
+ for _, run := range []string{"TestA", "(a/b)", "a[/]b", "A|B", "^A$/^B$", `^TestParent$/^a\+b\[1\]$`, "A//B", "A|", "(?Px)", "a b", `a\ b`, `a\/b`, `a\|b`, "", "\u088f", "A/\u088f",
+ // testing.rewrite escapes non-printable runes before compiling, so a
+ // backslash before one becomes an escaped backslash rather than an
+ // invalid escape, and "\a" stays the bell class it already was.
+ "\\\u200b", "\u0007", "a\u0007b", "\u00a0", "A/\\\u200b"} {
+ if o, err := parseOptions(map[string]any{"module_path": ".", "run": run}); err != nil || o.Run != run {
+ t.Fatalf("rejected valid run %q: %v", run, err)
+ }
+ }
+ // Expressions the test binary rejects at startup, which would otherwise fail
+ // every package and read as a project test failure.
+ for _, run := range []string{"[", "(a", "A/[", "a|(", "A/b)", `a\`, "((A)/B", "[a-", "A/*",
+ // The same rewrite turns a bare non-printable rune into "\u200b", which
+ // the regexp parser rejects; go test then fails every package instead.
+ "\u200b", "A/\u200b", "A|\u200b", "(\u200b)"} {
+ if _, err := parseOptions(map[string]any{"module_path": ".", "run": run}); err == nil {
+ t.Errorf("accepted invalid run %q", run)
+ }
+ }
+}
+
+// A failing package whose import path ends in ".go" must produce a rerun the
+// tool can accept again; the pattern check used to reject its own output.
+// The rewrite go test applies to every -run element decides whether the
+// element compiles, so rewriteRun must reproduce it rune for rune. Each
+// expectation below was read off a real "go test -run" run: feeding it "[" plus
+// the rune makes the element invalid, and the resulting error echoes the
+// rewritten form back. Note the space set is testing's hand-written switch, not
+// the Unicode Z class -- U+200A rewrites to "_" while U+200B, one past the end
+// of that range, becomes the escape "\u200b" the regexp parser rejects.
+// validateRun compiles one regexp per top-level element before runTests builds
+// the timeout context, so the expression is bounded by size rather than left to
+// spend that time outside the timeout and outside elapsed_seconds.
+func TestRunSizeIsBounded(t *testing.T) {
+ if _, err := parseOptions(map[string]any{"module_path": ".", "run": strings.Repeat("A", runLimit)}); err != nil {
+ t.Fatalf("rejected run at the limit: %v", err)
+ }
+ // Worst case for validation cost: every byte pair is its own element.
+ dense := strings.Repeat("A|", runLimit/2)
+ start := time.Now()
+ o, err := parseOptions(map[string]any{"module_path": ".", "run": dense})
+ if err != nil || o.Run != dense {
+ t.Fatalf("rejected dense run at the limit: %v", err)
+ }
+ if d := time.Since(start); d > 2*time.Second {
+ t.Errorf("validating a run at the limit took %s", d)
+ }
+ for _, run := range []string{strings.Repeat("A", runLimit+1), strings.Repeat("A|", runLimit)} {
+ if _, err := parseOptions(map[string]any{"module_path": ".", "run": run}); err == nil {
+ t.Errorf("accepted run of %d bytes", len(run))
+ }
+ }
+}
+
+func TestRewriteRunMatchesGoTest(t *testing.T) {
+ for in, want := range map[string]string{
+ "\t": "_", " ": "_", "\u0085": "_", "\u00a0": "_", "\u1680": "_",
+ "\u2000": "_", "\u2005": "_", "\u200a": "_", "\u2028": "_",
+ "\u202f": "_", "\u205f": "_", "\u3000": "_",
+ "\u200b": `\u200b`, "\a": `\a`, "\ufeff": `\ufeff`, "\ue000": `\ue000`,
+ // Unassigned under the pinned toolchain and printable under a newer one,
+ // so it must survive raw: "go test -run" accepts it on go1.27.1 and this
+ // server would otherwise reject a rerun it built itself.
+ "\u088f": "\u088f",
+ "TestA": "TestA",
+ "a b": "a_b",
+ "a\\\u200b": `a\\u200b`,
+ } {
+ if got := rewriteRun(in); got != want {
+ t.Errorf("rewriteRun(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func TestRerunRoundTripsThroughOptions(t *testing.T) {
+ s := newEventStream()
+ emit(s,
+ event{Action: "run", Package: "example.com/m/gen.go", Test: "TestParent/a+b[1]"},
+ event{Action: "fail", Package: "example.com/m/gen.go", Test: "TestParent/a+b[1]"},
+ event{Action: "fail", Package: "example.com/m/gen.go"},
+ )
+ s.finish()
+ r := s.result(Options{ModulePath: ".", Packages: []string{"./..."}, Timeout: 60}, &cappedBuffer{})
+ if r.Status != "failed" || len(r.Packages) != 1 || len(r.Packages[0].Failures) != 1 {
+ t.Fatalf("%+v", r)
+ }
+ rerun := r.Packages[0].Failures[0].Rerun
+ b, err := json.Marshal(rerun)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var args map[string]any
+ if err := json.Unmarshal(b, &args); err != nil {
+ t.Fatal(err)
+ }
+ o, err := parseOptions(args)
+ if err != nil {
+ t.Fatalf("rerun rejected: %v (%+v)", err, rerun)
+ }
+ if o.Packages[0] != "example.com/m/gen.go" || o.Run != rerun.Run {
+ t.Fatalf("%+v", o)
+ }
+ // go test spells non-printable runes in test names with their escape
+ // sequence, so exactRun must quote the backslash for the rerun to survive
+ // validateRun, which mirrors the same rewrite.
+ for _, name := range []string{`TestZero/a\u200bb`, "TestSpace/a b", "TestParent/a+b[1]"} {
+ if err := validateRun(exactRun(name)); err != nil {
+ t.Fatalf("rerun for %q rejected: %v", name, err)
+ }
+ }
}
func emit(s *eventStream, es ...event) {
@@ -276,12 +394,21 @@ func TestPackageValidation(t *testing.T) {
{Dir: outside, ImportPath: "p", Module: &struct{ Dir string }{root}},
{Dir: root, ImportPath: "p", Module: &struct{ Dir string }{outside}},
{Dir: root, ImportPath: "-bad", Module: &struct{ Dir string }{root}},
+ // go list reports a .go file list under this synthesized import path and
+ // without module framing.
+ {Dir: root, ImportPath: "command-line-arguments"},
} {
b, _ := json.Marshal(p)
if _, err := validatePackages(b, root); err == nil {
t.Fatalf("accepted %+v", p)
}
}
+ // A module may legitimately declare "command-line-arguments" as its path; go
+ // list then reports it with a module, and "go test" on it succeeds.
+ b, _ := json.Marshal(listedPackage{Dir: root, ImportPath: "command-line-arguments", Module: &struct{ Dir string }{root}})
+ if got, err := validatePackages(b, root); err != nil || len(got) != 1 || got[0] != "command-line-arguments" {
+ t.Fatalf("rejected module named command-line-arguments: %v %v", got, err)
+ }
link := filepath.Join(root, "link")
if err := os.Symlink(outside, link); err == nil {
p := listedPackage{Dir: link, ImportPath: "p", Module: &struct{ Dir string }{root}}
diff --git a/test-runner/src/runner.go b/test-runner/src/runner.go
index 1bcf2a6..d5878ec 100644
--- a/test-runner/src/runner.go
+++ b/test-runner/src/runner.go
@@ -51,6 +51,12 @@ func validatePackages(data []byte, root string) ([]string, error) {
if p.Error != nil {
return nil, fmt.Errorf("package discovery: %s", p.Error.Err)
}
+ // go list synthesizes this import path for a .go file list, which has no
+ // module framing and cannot be reproduced by a rerun. A module that
+ // declares this path is reported with a module and stays valid.
+ if p.ImportPath == "command-line-arguments" {
+ return nil, errors.New(".go file lists are not supported; pass package patterns")
+ }
return nil, fmt.Errorf("package %q is not in the selected module", p.ImportPath)
}
moduleDir, err := canonical(p.Module.Dir)