lint: detect unnecessarily bidirectional channels

This commit is contained in:
David Gageot committed 2026-10-15 21:55:31 +01:00
1 parent 19fa4a19ae
commit a2f028e6b3
19 files changed
+957 -22

No files matched your search

+13
View File
@@ -114,6 +114,19 @@ opt-in cops are currently selected. `Lint/ContextFirstParameter` and
intentionally owned lifecycle or telemetry contexts would require suppressions or
unrelated refactoring.
`Lint/ChannelDirection` suggests directional raw-channel parameters and results.
It follows requirements through locals, fields, direct calls, returns, and closure
captures, including internal/external tests. Closing requires send capability;
receiving and closing requires both. Connected declarations may need coordinated
changes, so there is no autofix. Generated and frozen declarations retain their
original contracts. Unknown calls, interface escapes, reflective type arguments,
function values, and unsupported structural conversions conservatively retain
capabilities. Tuple flows preserve both channel operations and aggregate escapes.
The check assumes the loaded program is the consumer: review exported API changes
for downstream users and validate other build targets. It covers the active build,
not every build-tag or platform variant; it does not prove channel ownership or
send/close race safety.
`Lint/HTTPRequestWithContext` enforces contextual HTTP request construction outside
frozen config versions. It inspects production files only; matching is syntactic,
so aliases and shadowing require care. `Lint/NoFatalOutsideMain` reserves
+720
View File
@@ -0,0 +1,720 @@
package main
import (
"fmt"
"go/ast"
"go/token"
"go/types"
"path/filepath"
"slices"
"github.com/dgageot/rubocop-go/cop"
"github.com/dgageot/rubocop-go/prog"
"golang.org/x/tools/go/packages"
)
// ChannelDirection follows channel capabilities backwards through assignments,
// fields, calls and returns. It suggests coordinated type changes, not autofixes.
// Unknown uses retain both capabilities; only the active build is analyzed.
var ChannelDirection = prog.New(cop.Meta{
Name: "Lint/ChannelDirection",
Description: "use directional channels when only one capability is required",
Severity: cop.Convention,
}, checkChannelDirection)
func checkChannelDirection(p *prog.Pass) {
var dirs []string
for _, pkg := range p.Program.Packages {
if pkg.Dir != "" {
dirs = append(dirs, pkg.Dir)
}
}
if len(dirs) == 0 {
return
}
pkgs, err := packages.Load(&packages.Config{
Mode: packages.NeedName | packages.NeedFiles | packages.NeedCompiledGoFiles |
packages.NeedImports | packages.NeedTypes | packages.NeedTypesInfo | packages.NeedSyntax,
Dir: dirs[0], Fset: p.Program.Fset, Tests: true,
}, dirs...)
if err != nil {
p.Reportf(token.NoPos, "cannot inspect channel directions: %v", err)
return
}
analyzeChannelDirections(p, pkgs)
}
type channelCapabilities uint8
const (
channelSend channelCapabilities = 1 << iota
channelReceive
channelBoth = channelSend | channelReceive
)
type channelSlot struct {
needs channelCapabilities
sources map[*channelSlot]bool
pos token.Pos
end token.Pos
label string
}
type channelValue struct {
slot *channelSlot
typ types.Type
}
type channelFunction struct {
params, results []*channelSlot
resultTypes []types.Type
}
type channelAnalysis struct {
fset *token.FileSet
slots map[string]*channelSlot
objects map[types.Object]*channelSlot
functions map[string]*channelFunction
info *types.Info
direct map[ast.Expr]bool
interfaces map[string][]*types.Signature
}
func analyzeChannelDirections(p *prog.Pass, pkgs []*packages.Package) {
a := &channelAnalysis{
fset: p.Program.Fset, slots: make(map[string]*channelSlot),
objects: make(map[types.Object]*channelSlot), functions: make(map[string]*channelFunction),
direct: make(map[ast.Expr]bool), interfaces: make(map[string][]*types.Signature),
}
visited := make(map[*types.Package]bool)
var interfaces func(*types.Package)
interfaces = func(pkg *types.Package) {
if pkg == nil || visited[pkg] {
return
}
visited[pkg] = true
for _, name := range pkg.Scope().Names() {
if obj, ok := pkg.Scope().Lookup(name).(*types.TypeName); ok {
if iface, ok := obj.Type().Underlying().(*types.Interface); ok {
for method := range iface.Complete().Methods() {
sig := method.Type().(*types.Signature)
a.interfaces[method.Name()] = append(a.interfaces[method.Name()], sig)
}
}
}
}
for _, dep := range pkg.Imports() {
interfaces(dep)
}
}
for _, pkg := range pkgs {
interfaces(pkg.Types)
}
for _, pkg := range pkgs {
a.info = pkg.TypesInfo
if a.info == nil {
continue
}
for _, file := range pkg.Syntax {
ast.Inspect(file, func(n ast.Node) bool {
if expr, ok := n.(ast.Expr); ok {
if typ := a.info.TypeOf(expr); typ != nil {
if iface, ok := typ.Underlying().(*types.Interface); ok {
for method := range iface.Complete().Methods() {
a.interfaces[method.Name()] = append(a.interfaces[method.Name()], method.Type().(*types.Signature))
}
}
}
}
return true
})
}
}
// Keep all test variants: they contribute constraints to the same source slots.
for _, pkg := range pkgs {
a.info = pkg.TypesInfo
if a.info == nil {
continue
}
for _, file := range pkg.Syntax {
editable := !pkg.IllTyped && !ast.IsGenerated(file) && !frozenConfigPath.MatchString(filepath.ToSlash(a.fset.PositionFor(file.Pos(), false).Filename))
ast.Inspect(file, func(n ast.Node) bool {
switch n := n.(type) {
case *ast.Ident:
if obj, ok := a.info.Defs[n].(*types.Var); ok {
a.variable(obj, editable)
}
case *ast.FuncDecl:
obj, _ := a.info.Defs[n.Name].(*types.Func)
if obj != nil {
sig := obj.Type().(*types.Signature)
a.registerFunction(a.key(obj.Pos()), sig, n.Type, editable && n.Body != nil)
if sig.Recv() != nil {
for _, iface := range a.interfaces[obj.Name()] {
if sig.Recv() == nil || types.Identical(sig.Params(), iface.Params()) && types.Identical(sig.Results(), iface.Results()) {
a.freezeFunction(a.functions[a.key(obj.Pos())])
}
}
}
}
case *ast.FuncLit:
if typ := a.info.TypeOf(n); typ != nil {
a.registerFunction(a.key(n.Pos()), typ.(*types.Signature), n.Type, editable)
}
}
return true
})
}
}
for _, pkg := range pkgs {
a.info = pkg.TypesInfo
if a.info == nil {
continue
}
// Generic function values can hide type arguments absent from their signatures.
for _, instantiation := range a.info.Instances {
for typ := range instantiation.TypeArgs.Types() {
a.escapeType(typ, make(map[types.Type]bool))
}
}
for _, file := range pkg.Syntax {
a.inspect(file, nil)
}
}
for _, pkg := range pkgs {
if pkg.IllTyped {
return
} // Do not infer capabilities from incomplete uses.
}
// Each bit is added at most once per slot, including recursive call cycles.
queue := make([]*channelSlot, 0, len(a.slots))
for _, slot := range a.slots {
if slot.needs != 0 {
queue = append(queue, slot)
}
}
for len(queue) > 0 {
slot := queue[0]
queue = queue[1:]
for source := range slot.sources {
if source.needs|slot.needs != source.needs {
source.needs |= slot.needs
queue = append(queue, source)
}
}
}
var findings []*channelSlot
for _, slot := range a.slots {
if slot.label != "" && (slot.needs == channelSend || slot.needs == channelReceive) {
findings = append(findings, slot)
}
}
slices.SortFunc(findings, func(x, y *channelSlot) int { return int(x.pos - y.pos) })
for _, slot := range findings {
direction := "send-only (chan<- T)"
if slot.needs == channelReceive {
direction = "receive-only (<-chan T)"
}
p.ReportAtf(slot.pos, slot.end, "%s can be %s; linked channel declarations may need narrowing too", slot.label, direction)
}
}
func (a *channelAnalysis) key(pos token.Pos) string {
p := a.fset.PositionFor(pos, false)
return fmt.Sprintf("%s:%d", p.Filename, p.Offset)
}
func (a *channelAnalysis) slot(key string) *channelSlot {
if a.slots[key] == nil {
a.slots[key] = &channelSlot{sources: make(map[*channelSlot]bool)}
}
return a.slots[key]
}
func channelType(t types.Type) *types.Chan {
if t == nil {
return nil
}
ch, _ := types.Unalias(t).Underlying().(*types.Chan)
return ch
}
func fixedChannelNeeds(t types.Type) channelCapabilities {
if ch := channelType(t); ch != nil {
switch ch.Dir() {
case types.SendOnly:
return channelSend
case types.RecvOnly:
return channelReceive
}
}
return channelBoth
}
func (a *channelAnalysis) variable(obj types.Object, editable bool) *channelSlot {
if obj == nil || channelType(obj.Type()) == nil {
return nil
}
if slot := a.objects[obj]; slot != nil {
return slot
}
slot := a.slot(a.key(obj.Pos()))
a.objects[obj] = slot
_, raw := obj.Type().(*types.Chan)
if !editable || !raw || channelType(obj.Type()).Dir() != types.SendRecv {
slot.needs |= fixedChannelNeeds(obj.Type())
}
return slot
}
func (a *channelAnalysis) registerFunction(key string, sig *types.Signature, syntax *ast.FuncType, editable bool) {
fn := a.functions[key]
if fn == nil {
fn = &channelFunction{}
a.functions[key] = fn
}
register := func(tuple *types.Tuple, fields *ast.FieldList, kind string) []*channelSlot {
slots := make([]*channelSlot, tuple.Len())
index := 0
if fields == nil {
return slots
}
for _, field := range fields.List {
for j := range max(1, len(field.Names)) {
obj := tuple.At(index)
if ch := channelType(obj.Type()); ch != nil {
slot := a.slot(fmt.Sprintf("%s:%s:%d", key, kind, index))
a.objects[obj] = slot
slots[index] = slot
_, raw := field.Type.(*ast.ChanType)
if !editable || !raw || ch.Dir() != types.SendRecv {
slot.needs |= fixedChannelNeeds(obj.Type())
} else {
slot.pos, slot.end = field.Type.Pos(), field.Type.End()
slot.label = kind
if len(field.Names) > 0 {
slot.pos, slot.end = field.Names[j].Pos(), field.Names[j].End()
slot.label += " " + field.Names[j].Name
}
}
}
index++
}
}
return slots
}
fn.params = register(sig.Params(), syntax.Params, "parameter")
fn.results = register(sig.Results(), syntax.Results, "result")
fn.resultTypes = nil
for v := range sig.Results().Variables() {
fn.resultTypes = append(fn.resultTypes, v.Type())
}
}
func (a *channelAnalysis) object(expr ast.Expr) types.Object {
switch expr := ast.Unparen(expr).(type) {
case *ast.Ident:
return a.info.ObjectOf(expr)
case *ast.SelectorExpr:
return a.info.ObjectOf(expr.Sel)
}
return nil
}
func (a *channelAnalysis) function(expr ast.Expr) *channelFunction {
expr = ast.Unparen(expr)
if fn, ok := expr.(*ast.FuncLit); ok {
return a.functions[a.key(fn.Pos())]
}
if obj, ok := a.object(expr).(*types.Func); ok {
return a.functions[a.key(obj.Origin().Pos())]
}
return nil
}
func (a *channelAnalysis) value(expr ast.Expr) *channelSlot {
if expr == nil || channelType(a.info.TypeOf(expr)) == nil {
return nil
}
expr = ast.Unparen(expr)
if obj := a.object(expr); obj != nil {
if slot := a.objects[obj]; slot != nil {
return slot
}
return a.variable(obj, false)
}
if call, ok := expr.(*ast.CallExpr); ok {
if fn := a.function(call.Fun); fn != nil && len(fn.results) == 1 {
return fn.results[0]
}
}
return a.slot(a.key(expr.Pos()) + ":value")
}
func (a *channelAnalysis) destination(expr ast.Expr) *channelSlot {
switch ast.Unparen(expr).(type) {
case *ast.Ident, *ast.SelectorExpr:
return a.value(expr)
}
return nil
}
func (a *channelAnalysis) markDirect(expr ast.Expr) {
expr = ast.Unparen(expr)
a.direct[expr] = true
if sel, ok := expr.(*ast.SelectorExpr); ok {
a.direct[sel.Sel] = true
}
}
func channelFlow(source, dest *channelSlot) {
if source != nil && dest != nil && source != dest {
dest.sources[source] = true
}
}
func (a *channelAnalysis) require(expr ast.Expr, needs channelCapabilities) {
if slot := a.value(expr); slot != nil {
slot.needs |= needs
}
}
func (a *channelAnalysis) bind(expr ast.Expr, dest *channelSlot, typ types.Type) {
a.bindValue(channelValue{a.value(expr), a.info.TypeOf(expr)}, dest, typ)
}
func (a *channelAnalysis) bindValue(source channelValue, dest *channelSlot, typ types.Type) {
if source.slot != nil {
if dest != nil {
channelFlow(source.slot, dest)
} else {
source.slot.needs |= fixedChannelNeeds(typ)
a.escapeType(source.typ, make(map[types.Type]bool))
}
return
}
if source.typ == nil {
return
}
underlying := types.Unalias(source.typ)
if ptr, ok := underlying.(*types.Pointer); ok {
underlying = types.Unalias(ptr.Elem())
}
_, anonymous := underlying.(*types.Struct)
iface := isChannelInterface(typ)
if anonymous || iface || !types.Identical(source.typ, typ) {
a.escapeType(source.typ, make(map[types.Type]bool))
}
}
func isChannelInterface(typ types.Type) bool {
if typ == nil {
return false
}
_, ok := typ.Underlying().(*types.Interface)
return ok
}
// values flattens tuple expressions without dropping aggregate result types.
func (a *channelAnalysis) values(exprs []ast.Expr) []channelValue {
if len(exprs) == 1 {
if tuple, ok := a.info.TypeOf(exprs[0]).(*types.Tuple); ok {
values := make([]channelValue, tuple.Len())
var fn *channelFunction
if call, ok := ast.Unparen(exprs[0]).(*ast.CallExpr); ok {
fn = a.function(call.Fun)
}
for i := range tuple.Len() {
values[i].typ = tuple.At(i).Type()
if fn != nil && i < len(fn.results) {
values[i].slot = fn.results[i]
}
}
return values
}
}
values := make([]channelValue, len(exprs))
for i, expr := range exprs {
values[i] = channelValue{a.value(expr), a.info.TypeOf(expr)}
}
return values
}
// inspect owns traversal so each channel use is classified, including closure captures.
func (a *channelAnalysis) inspect(n ast.Node, fn *channelFunction) {
if n == nil {
return
}
switch n := n.(type) {
case *ast.FuncDecl:
obj, _ := a.info.Defs[n.Name].(*types.Func)
if obj != nil && n.Body != nil {
a.inspect(n.Body, a.functions[a.key(obj.Pos())])
}
return
case *ast.FuncLit:
if !a.direct[n] {
a.freezeFunction(a.function(n))
}
a.inspect(n.Body, a.functions[a.key(n.Pos())])
return
case *ast.Ident, *ast.SelectorExpr:
if expr, ok := n.(ast.Expr); ok && !a.direct[expr] {
a.freezeFunction(a.function(expr))
if typ := a.info.TypeOf(expr); typ != nil {
if _, ok := typ.Underlying().(*types.Signature); ok {
a.escapeType(typ, make(map[types.Type]bool))
}
}
}
case *ast.FuncType, *ast.ChanType:
return
case *ast.AssignStmt:
a.assign(n.Lhs, n.Rhs)
case *ast.ValueSpec:
lhs := make([]ast.Expr, len(n.Names))
for i, name := range n.Names {
lhs[i] = name
}
a.assign(lhs, n.Values)
case *ast.ReturnStmt:
if fn != nil {
a.bindResults(n.Results, fn)
}
case *ast.SendStmt:
a.require(n.Chan, channelSend)
ch := channelType(a.info.TypeOf(n.Chan))
if ch != nil {
a.bind(n.Value, nil, ch.Elem())
}
case *ast.RangeStmt:
a.require(n.X, channelReceive)
case *ast.UnaryExpr:
switch n.Op {
case token.ARROW:
a.require(n.X, channelReceive)
case token.AND:
a.require(n.X, channelBoth)
}
case *ast.BinaryExpr:
a.compare(n.X, n.Y)
case *ast.SwitchStmt:
if n.Tag != nil {
for _, stmt := range n.Body.List {
clause, ok := stmt.(*ast.CaseClause)
if !ok {
continue
}
for _, expr := range clause.List {
a.compare(n.Tag, expr)
}
}
}
case *ast.CallExpr:
a.call(n)
case *ast.CompositeLit:
a.composite(n)
}
ast.Inspect(n, func(child ast.Node) bool {
if child == n {
return true
}
if child != nil {
a.inspect(child, fn)
}
return false
})
}
func (a *channelAnalysis) compare(left, right ast.Expr) {
leftInterface := isChannelInterface(a.info.TypeOf(left))
rightInterface := isChannelInterface(a.info.TypeOf(right))
if a.value(left) != nil && (a.value(right) != nil || rightInterface) ||
a.value(right) != nil && leftInterface {
a.require(left, channelBoth)
a.require(right, channelBoth)
}
}
func (a *channelAnalysis) assign(lhs, rhs []ast.Expr) {
for i, source := range a.values(rhs) {
if i >= len(lhs) {
break
}
if id, ok := lhs[i].(*ast.Ident); ok && id.Name == "_" {
continue
}
a.bindValue(source, a.destination(lhs[i]), a.info.TypeOf(lhs[i]))
}
}
func (a *channelAnalysis) bindResults(exprs []ast.Expr, target *channelFunction) {
for i, source := range a.values(exprs) {
if i >= len(target.results) {
break
}
a.bindValue(source, target.results[i], target.resultTypes[i])
}
}
func (a *channelAnalysis) call(call *ast.CallExpr) {
a.markDirect(call.Fun)
if obj, ok := a.object(call.Fun).(*types.Builtin); ok {
switch obj.Name() {
case "close":
if len(call.Args) == 1 {
a.require(call.Args[0], channelSend)
}
case "append":
values := a.values(call.Args)
if len(values) > 1 && !call.Ellipsis.IsValid() && values[0].typ != nil {
if slice, ok := values[0].typ.Underlying().(*types.Slice); ok {
for _, source := range values[1:] {
a.bindValue(source, nil, slice.Elem())
}
}
}
case "len", "cap", "make":
default:
for _, source := range a.values(call.Args) {
a.bindValue(source, nil, nil)
a.escapeType(source.typ, make(map[types.Type]bool))
}
}
return
}
if a.info.Types[call.Fun].IsType() {
for _, arg := range call.Args {
a.escapeType(a.info.TypeOf(arg), make(map[types.Type]bool))
a.bind(arg, nil, a.info.TypeOf(call))
}
return
}
callType := a.info.TypeOf(call.Fun)
if callType == nil {
return
}
sig, _ := callType.Underlying().(*types.Signature)
if sig == nil {
return
}
callee := a.function(call.Fun)
if sel, ok := ast.Unparen(call.Fun).(*ast.SelectorExpr); ok {
if selection := a.info.Selections[sel]; selection != nil && selection.Kind() == types.MethodExpr {
// Method expressions prepend the receiver; keep their signatures fixed.
a.freezeFunction(callee)
callee = nil
}
}
for i, source := range a.values(call.Args) {
index := i
if sig.Variadic() && index >= sig.Params().Len()-1 {
index = sig.Params().Len() - 1
}
if index < 0 || index >= sig.Params().Len() {
a.bindValue(source, nil, nil)
continue
}
var dest *channelSlot
if callee != nil && index < len(callee.params) {
dest = callee.params[index]
}
typ := sig.Params().At(index).Type()
if sig.Variadic() && !call.Ellipsis.IsValid() && index == sig.Params().Len()-1 {
if slice, ok := typ.Underlying().(*types.Slice); ok {
typ = slice.Elem()
}
}
if callee == nil {
a.escapeType(source.typ, make(map[types.Type]bool))
}
a.bindValue(source, dest, typ)
}
}
func (a *channelAnalysis) freezeFunction(fn *channelFunction) {
if fn == nil {
return
}
for _, slots := range [][]*channelSlot{fn.params, fn.results} {
for _, slot := range slots {
if slot != nil {
slot.needs |= channelBoth
}
}
}
}
func (a *channelAnalysis) escapeType(typ types.Type, seen map[types.Type]bool) {
if typ == nil || seen[typ] {
return
}
seen[typ] = true
switch typ := typ.Underlying().(type) {
case *types.Pointer:
a.escapeType(typ.Elem(), seen)
case *types.Struct:
for field := range typ.Fields() {
if slot := a.variable(field, false); slot != nil {
slot.needs |= channelBoth
}
a.escapeType(field.Type(), seen)
}
case *types.Array:
a.escapeType(typ.Elem(), seen)
case *types.Chan:
a.escapeType(typ.Elem(), seen)
case *types.Slice:
a.escapeType(typ.Elem(), seen)
case *types.Map:
a.escapeType(typ.Key(), seen)
a.escapeType(typ.Elem(), seen)
case *types.Signature:
for v := range typ.Params().Variables() {
a.escapeType(v.Type(), seen)
}
for v := range typ.Results().Variables() {
a.escapeType(v.Type(), seen)
}
case *types.Interface:
for method := range typ.Complete().Methods() {
a.escapeType(method.Type(), seen)
}
}
}
func (a *channelAnalysis) composite(lit *ast.CompositeLit) {
typ := a.info.TypeOf(lit)
if typ == nil {
return
}
st, _ := typ.Underlying().(*types.Struct)
for i, elt := range lit.Elts {
expr := elt
var field types.Object
if kv, ok := elt.(*ast.KeyValueExpr); ok {
expr = kv.Value
if mapping, ok := typ.Underlying().(*types.Map); ok {
a.bind(kv.Key, nil, mapping.Key())
a.bind(kv.Value, nil, mapping.Elem())
continue
}
field = a.object(kv.Key)
} else if st != nil && i < st.NumFields() {
field = st.Field(i)
}
if field != nil {
a.bind(expr, a.variable(field, false), field.Type())
} else {
switch container := typ.Underlying().(type) {
case *types.Array:
a.bind(expr, nil, container.Elem())
case *types.Slice:
a.bind(expr, nil, container.Elem())
default:
a.require(expr, channelBoth)
a.escapeType(a.info.TypeOf(expr), make(map[types.Type]bool))
}
}
}
}
+201
View File
@@ -0,0 +1,201 @@
package main
import (
"fmt"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/dgageot/rubocop-go/coptest"
"github.com/stretchr/testify/assert"
)
func TestChannelDirection(t *testing.T) {
t.Parallel()
tests := []struct {
name, src string
want []string
}{
{"send", `func f(c chan int) { c <- 1 }`, []string{"parameter c:send-only"}},
{"close", `func f(c chan int) { close(c) }`, []string{"parameter c:send-only"}},
{"receive", `func f(c chan int) { <-c }`, []string{"parameter c:receive-only"}},
{"range", `func f(c chan int) { for range c {} }`, []string{"parameter c:receive-only"}},
{"both", `func f(c chan int) { close(c); <-c }`, nil},
{"unused", `func f(c chan int) { _ = len(c); _ = cap(c); _ = c == nil }`, nil},
{"alias", `func f(c chan int) { x := c; <-x }`, []string{"parameter c:receive-only"}},
{"helper", `func f(c chan int) { g(c) }; func g(d chan int) { close(d) }`, []string{"parameter c:send-only", "parameter d:send-only"}},
{"cycle", `func f(c chan int) { g(c) }; func g(d chan int) { <-d; f(d) }`, []string{"parameter c:receive-only", "parameter d:receive-only"}},
{"sink", `type sink struct{ c chan int }; func newSink(c chan int) *sink { return &sink{c:c} }; func (s *sink) emit() { s.c <- 1 }`, []string{"parameter c:send-only"}},
{"sink interface", `type Sink interface{ emit() }; type sink struct{ c chan int }; func newSink(c chan int) Sink { return &sink{c:c} }; func (s *sink) emit() { s.c <- 1 }`, nil},
{"producer", `func f() chan int { c := make(chan int); go func(){ c <- 1; close(c) }(); return c }; func g(){ <-f() }`, []string{"result:receive-only"}},
{"passthrough", `func f(c chan int) chan int { return c }; func g(){ <-f(make(chan int)) }`, []string{"parameter c:receive-only", "result:receive-only"}},
{"fixed receiver", `func f(c chan int) { close(c); g(c) }; func g(<-chan int) {}`, nil},
{"explicit conversion", `func f(c chan int) { <-c; _ = (chan int)(c) }`, nil},
{"directional conversion", `func f(c chan int) { _ = (<-chan int)(c) }`, []string{"parameter c:receive-only"}},
{"function value", `func f(c chan int) { <-c }; var callback func(chan int) = f`, nil},
{"method interface", `type I interface{ Read(chan int) }; type T struct{}; func (T) Read(c chan int) { <-c }; var _ I = T{}`, nil},
{"unknown callback", `func f(c chan int, g func(chan int)) { g(c); <-c }`, nil},
{"boxed channel", `func f(c chan int) any { <-c; return c }`, nil},
{"channel comparison", `func f(a,b chan int) bool { close(a); <-b; return a == b }`, nil},
{"address", `func f(c chan int) *chan int { <-c; return &c }`, nil},
{"stored interface", `func f(c chan int) { var a any = c; _ = a; <-c }`, nil},
{"container", `func f(c chan int) []chan int { <-c; return []chan int{c} }`, nil},
{"channel element", `func f(dst chan chan int, c chan int) { dst <- c; <-c }`, []string{"parameter dst:send-only"}},
{"named", `type C chan int; func f(c C) { <-c }`, nil},
{"type alias", `type C = chan int; func f(c C) { <-c }`, nil},
{"closure", `func f(c chan int) { go func(){ <-c }() }`, []string{"parameter c:receive-only"}},
{"tuple", `func f() (chan int, int) { return make(chan int), 1 }; func g(){ c,_ := f(); <-c }`, []string{"result:receive-only"}},
{"named result", `func f() (c chan int) { c = make(chan int); return }; func g(){ <-f() }`, []string{"result c:receive-only"}},
{"generic", `func g[C ~chan int](c C){ <-c }; func f(c chan int){ g(c); <-c }`, nil},
{"named struct conversion", `type A struct{c chan int}; type B struct{c chan int}; func f(c chan int){ <-c; b:=B(A{c}); b.c<-1 }`, nil},
{"literal assigned", `var callback func(chan int) = func(c chan int){ <-c }`, nil},
{"literal passed", `func g(func(chan int)){}; func f(){ g(func(c chan int){ <-c }) }`, nil},
{"map store", `func f(c chan int, m map[string]chan int){ <-c; m["x"] = c }`, nil},
{"pointer store", `func f(c chan int, p *chan int){ <-c; *p = c }`, nil},
{"map key", `func f(c chan int){ <-c; _ = map[chan int]int{c:1} }`, nil},
{"anonymous interface", `type T struct{}; func (T) Read(c chan int){ <-c }; var _ interface{Read(chan int)} = T{}`, nil},
{"local interface", `type T struct{}; func (T) Read(c chan int){ <-c }; func f(){ type I interface{Read(chan int)}; var _ I = T{} }`, nil},
{"tuple arguments", `func pair()(chan int,int){ return make(chan int),0 }; func send(c chan int,n int){ c<-n }; func f(){ send(pair()); c,_:=pair(); <-c }`, []string{"parameter c:send-only"}},
{"boxed tuple return", `func pair()(chan int,int){ return make(chan int),0 }; func f()(any,int){ return pair() }; func g(){ c,_:=pair(); <-c }`, nil},
{"boxed struct", `func f(c chan int) any { <-c; return struct{ C chan int }{c} }`, nil},
{"anonymous struct result", `func f(c chan int) struct{ C chan int }{ <-c; return struct{ C chan int }{c} }; func g(){ f(make(chan int)).C<-1 }`, nil},
{"method direct", `type T struct{}; func(T) f(c chan int){ <-c }; func g(c chan int){ T{}.f(c) }`, []string{"parameter c:receive-only", "parameter c:receive-only"}},
{"method expression", `type T struct{}; func(T) f(c chan int){ <-c }; func g(c chan int){ T.f(T{},c); <-c }`, nil},
{"aggregate escape", `type box struct{ c chan int }; func f(c chan int, g func(box)){ g(box{c:c}); <-c }`, nil},
{"structural assignment", `type Box struct{ C chan int }; func f(c chan int){ <-c; b:=Box{c}; var out struct{ C chan int }=b; out.C<-1 }`, nil},
{"structural pointer assignment", `type Box struct{ C chan int }; func f(c chan int){ <-c; b:=&Box{c}; var out *struct{ C chan int }=(*struct{ C chan int })(b); out.C<-1 }`, nil},
{"structural argument", `type Box struct{ C chan int }; func g(b struct{ C chan int }){ b.C<-1 }; func f(c chan int){ <-c; g(Box{c}) }`, nil},
{"structural return", `type Box struct{ C chan int }; func f(c chan int) struct{ C chan int }{ <-c; return Box{c} }; func g(){ f(make(chan int)).C<-1 }`, nil},
{"anonymous aliases", `type A=struct{ C chan int }; type B=struct{ C chan int }; func f(c chan int){ <-c; a:=A{c}; var b B=a; b.C<-1 }`, nil},
{"aggregate container element", `type Box struct{ C chan int }; func f(c chan int){ <-c; out:=[]struct{ C chan int }{Box{c}}; out[0].C<-1 }`, nil},
{"aggregate map element", `type Box struct{ C chan int }; func f(c chan int){ <-c; out:=map[int]struct{ C chan int }{0:Box{c}}; out[0].C<-1 }`, nil},
{"tuple interface argument", `import "reflect"; type Box struct{ C chan int }; func pair(c chan int)(Box,int){ <-c; return Box{c},0 }; func observe(v any, _ int) reflect.ChanDir { return reflect.TypeOf(v).Field(0).Type.ChanDir() }; func g(c chan int){ observe(pair(c)) }`, nil},
{"tuple interface assignment", `type Box struct{ C chan int }; func pair(c chan int)(Box,int){ <-c; return Box{c},0 }; func g(c chan int) any { var v any; v,_=pair(c); return v }`, nil},
{"tuple interface return", `type Box struct{ C chan int }; func pair(c chan int)(Box,int){ <-c; return Box{c},0 }; func g(c chan int)(any,int){ return pair(c) }`, nil},
{"tuple unknown callback", `type Box struct{ C chan int }; func pair(c chan int)(Box,int){ <-c; return Box{c},0 }; func g(c chan int, cb func(Box,int)){ cb(pair(c)) }`, nil},
{"tuple variadic argument", `type Box struct{ C chan int }; func pair(c chan int)(int,Box){ <-c; return 0,Box{c} }; func observe(_ int, vs ...any) any { return vs }; func g(c chan int){ observe(pair(c)) }`, nil},
{"tuple variadic only", `type Box struct{ C chan int }; func pair(c chan int)(Box,int){ <-c; return Box{c},0 }; func observe(vs ...any) any { return vs }; func g(c chan int){ observe(pair(c)) }`, nil},
{"boxed channel aggregate", `type Box struct{ C chan int }; func f(c chan int) any { <-c; q:=make(chan Box,1); q<-Box{c}; return q }`, nil},
{"nonempty interface escape", `type Box struct{ C chan int }; func(Box) Name()string{return "box"}; type I interface{Name()string}; func f(c chan int) I { <-c; return Box{c} }`, nil},
{"panic aggregate", `type Box struct{ C chan int }; func f(c chan int){ <-c; panic(Box{c}) }`, nil},
{"append interface aggregate", `type Box struct{ C chan int }; func f(c chan int) []any { <-c; return append([]any{},Box{c}) }`, nil},
{"append directional element", `func f(c chan int) []<-chan int { return append([]<-chan int{},c) }`, []string{"parameter c:receive-only"}},
{"type-only reflection", `import "reflect"; type Box struct{ C chan int }; func f(c chan int) Box { <-c; return Box{c} }; func direction() reflect.ChanDir { return reflect.TypeFor[Box]().Field(0).Type.ChanDir() }`, nil},
{"pointer type-only reflection", `import "reflect"; type Box struct{ C chan int }; func f(c chan int) Box { <-c; return Box{c} }; func direction() reflect.ChanDir { return reflect.TypeFor[*Box]().Elem().Field(0).Type.ChanDir() }`, nil},
{"nested type-only reflection", `import "reflect"; type Box struct{ C chan int }; func f(c chan int) Box { <-c; return Box{c} }; func direction() reflect.Type { return reflect.TypeFor[map[string][]Box]() }`, nil},
{"nonempty interface argument", `type Box struct{ C chan int }; func(Box) Name() string { return "box" }; type I interface{ Name() string }; func keep(v I) I { return v }; func f(c chan int) I { <-c; return keep(Box{c}) }`, nil},
{"unknown generic type arguments", `type Box struct{ C chan int }; func f(c chan int) Box { <-c; return Box{c} }; func observe[A,B any](){}; func g(){ observe[int,Box]() }`, nil},
{"tuple directional argument", `func pair()(chan int,int){ return make(chan int),0 }; func read(c <-chan int,_ int){ <-c }; func g(){ read(pair()) }`, []string{"result:receive-only"}},
{"switch channel comparison", `func f(c chan int,d chan<- int)bool{ <-c; switch c { case d:return true }; return false }`, nil},
{"switch function result", `func f(c chan int)chan int{return c}; func g(d chan<- int){ c:=f(make(chan int)); <-c; switch c { case d: } }`, nil},
{"interface equality", `func f(c chan int,v any)bool{ <-c; return c==v }`, nil},
{"interface switch", `func f(c chan int,v any)bool{ <-c; switch c { case v:return true }; return false }`, nil},
{"interface switch tag", `func f(c chan int,v any)bool{ <-c; switch v { case c:return true }; return false }`, nil},
{"append tuple", `func pair(c chan int)([]chan int,chan int){ <-c; return nil,c }; func f(c chan int){ xs:=append(pair(c)); xs[0]<-1 }`, nil},
{"delete tuple", `type Box struct{ C chan int }; func pair(c chan int)(map[any]int,any){ <-c; return nil,Box{c} }; func f(c chan int){ delete(pair(c)) }`, nil},
{"function type reflection", `import "reflect"; type Box struct{ C chan int }; func f(c chan int) Box { <-c; return Box{c} }; func observe() reflect.Type { return reflect.TypeFor[func() Box]() }`, nil},
{"interface method type reflection", `import "reflect"; type Box struct{ C chan int }; func f(c chan int) Box { <-c; return Box{c} }; func observe() reflect.Type { return reflect.TypeFor[interface{ Get() Box }]() }`, nil},
{"boxed function", `type Box struct{ C chan int }; func f(c chan int) Box { <-c; return Box{c} }; var v any=f`, nil},
{"inferred generic constraint", `type Box struct{ C chan int }; func accept[T ~struct{ C chan int }](v T){}; func f(c chan int){ <-c; accept(Box{c}) }`, nil},
{"inferred generic reflection", `import "reflect"; type Box struct{ C chan int }; func accept[T any](v T) reflect.Type{return reflect.TypeOf(v)}; func f(c chan int){ <-c; accept(Box{c}) }`, nil},
{"inferred generic type reflection", `import "reflect"; type Box struct{ C chan int }; func accept[T any](v T) reflect.Type{return reflect.TypeFor[T]()}; func f(c chan int){ <-c; accept(Box{c}) }`, nil},
{"generic function value constraint", `type Box struct{ C chan int }; func f(c chan int) Box { <-c; return Box{c} }; func accept[T ~struct{ C chan int }](){}; var invoke=accept[Box]; func g(){ invoke() }`, nil},
{"generic function value reflection", `import "reflect"; type Box struct{ C chan int }; func f(c chan int) Box { <-c; return Box{c} }; var observe=reflect.TypeFor[Box]; func g() reflect.Type { return observe() }`, nil},
{"switch nil", `func f(c chan int)bool{ <-c; switch c { case nil:return true }; return false }`, []string{"parameter c:receive-only"}},
}
files := coptest.ProgramFiles{"go.mod": "module example.test\n\ngo 1.27\n"}
for i, tc := range tests {
files[fmt.Sprintf("case%d/sample.go", i)] = "package p\n" + tc.src
}
offenses := coptest.RunProgram(t, ChannelDirection, files)
got := make(map[string][]string)
for _, offense := range offenses {
label, rest, ok := strings.Cut(offense.Message, " can be ")
assert.True(t, ok)
direction, _, _ := strings.Cut(rest, " ")
key := filepath.Base(filepath.Dir(offense.Pos.Filename))
got[key] = append(got[key], label+":"+direction)
}
for i, tc := range tests {
assert.ElementsMatch(t, tc.want, got[fmt.Sprintf("case%d", i)], tc.name)
}
}
func TestChannelDirectionPackagesAndTests(t *testing.T) {
t.Parallel()
offenses := coptest.RunProgram(t, ChannelDirection, coptest.ProgramFiles{
"go.mod": "module example.test\n\ngo 1.27\n",
"sink/sink.go": `package sink
func Send(c chan int) { c <- 1 }
func Read(c chan int) { <-c }
func Both(c chan int) { <-c }
`,
"sink/sink_test.go": `package sink
import "testing"
func TestBoth(t *testing.T) { c := make(chan int,1); c <- 1; Both(c) }
func send(c chan int) { c <- 1; Both(c) }
func testHelper(c chan int) { close(c) }
var _ func(chan int) = Both
`,
"consumer/consumer.go": `package consumer
import "example.test/sink"
func Forward(c chan int) { sink.Send(c) }
`,
"sink/external_test.go": `package sink_test
import "example.test/sink"
func helper(c chan int) { sink.Read(c) }
`,
"testonly/helper_test.go": `package testonly
func helper(c chan int) { <-c }
`,
})
var got []string
for _, offense := range offenses {
got = append(got, filepath.Base(offense.Pos.Filename)+":"+strconv.Itoa(offense.Pos.Line))
}
assert.ElementsMatch(t, []string{"sink.go:2", "sink.go:3", "consumer.go:3", "sink_test.go:5", "external_test.go:3", "helper_test.go:2"}, got)
}
func TestChannelDirectionScopes(t *testing.T) {
t.Parallel()
const source = "package p\nfunc Send(c chan int) { c <- 1 }\n"
offenses := coptest.RunProgram(t, ChannelDirection, coptest.ProgramFiles{
"pkg/config/v0/sample.go": source,
"pkg/config/latest/sample.go": source,
"generated/sample.go": "// Code generated by test; DO NOT EDIT.\n" + source,
"caller/sample.go": `package p
import old "example.test/pkg/config/v0"
func f(c chan int) { old.Send(c) }
`,
})
assert.Len(t, offenses, 1)
if len(offenses) == 1 {
assert.Contains(t, filepath.ToSlash(offenses[0].Pos.Filename), "/latest/")
}
}
func TestChannelDirectionBridge(t *testing.T) {
t.Parallel()
offenses := coptest.RunProgram(t, ChannelDirection, coptest.ProgramFiles{
"bridge.go": `package p
type bridge struct { ch chan int }
func (b *bridge) swap(ch chan int) chan int { prev := b.ch; b.ch = ch; return prev }
func (b *bridge) restore(current, previous chan int) { b.ch = previous; close(current) }
func (b *bridge) send() { b.ch <- 1 }
func (b *bridge) run(events chan int) { prev := b.swap(events); b.restore(events, prev) }
`,
})
assert.Len(t, offenses, 5)
for _, offense := range offenses {
assert.Contains(t, offense.Message, "send-only")
}
}
func TestChannelDirectionInvalidTests(t *testing.T) {
t.Parallel()
assert.Empty(t, coptest.RunProgram(t, ChannelDirection, coptest.ProgramFiles{
"sample.go": "package p\nfunc f(c chan int) { <-c }",
"sample_test.go": "package p\nfunc broken() { close() }\nfunc bodyless(c chan int)",
}))
}
+1
View File
@@ -76,6 +76,7 @@ var programCops = []prog.Cop{
rubocops.NewLintCutSuffix(cop.WithScope(outsideFrozenConfig)),
rubocops.NewLintFieldsSeqLookup(),
SlicesConcat,
ChannelDirection,
SessionStateAccessors,
rubocops.NewLintStreamCloseSafety(),
ExclusiveStreamLease,
+2 -2
View File
@@ -23,7 +23,7 @@ import (
func TestSharedCopRegistrations(t *testing.T) {
t.Parallel()
require.Len(t, cops, 31)
require.Len(t, programCops, 28)
require.Len(t, programCops, 29)
counts := make(map[string]int)
for _, c := range cops {
counts[c.Name()]++
@@ -31,7 +31,7 @@ func TestSharedCopRegistrations(t *testing.T) {
for _, c := range programCops {
counts[c.Name()]++
}
assert.Len(t, counts, 59)
assert.Len(t, counts, 60)
for _, name := range []string{"Lint/ContextFirstParameter", "Lint/NoContextField"} {
assert.NotContains(t, counts, name)
}
+1 -1
View File
@@ -591,7 +591,7 @@ func (a *App) LookupCommand(ctx context.Context, userInput string) (types.Comman
}
// EmitStartupInfo emits initial agent, team, and toolset information to the provided channel
func (a *App) EmitStartupInfo(ctx context.Context, events chan runtime.Event) {
func (a *App) EmitStartupInfo(ctx context.Context, events chan<- runtime.Event) {
a.runtime.EmitStartupInfo(ctx, a.session, runtime.NewChannelSink(events))
}
+2 -2
View File
@@ -203,7 +203,7 @@ func resolveKeys() {
// model is the top-level bubbletea model of the board.
type model struct {
app *board.App
refresh chan struct{}
refresh <-chan struct{}
width, height int
@@ -261,7 +261,7 @@ type model struct {
dragCol int
}
func newModel(app *board.App, refresh chan struct{}) *model {
func newModel(app *board.App, refresh <-chan struct{}) *model {
resolveKeys()
m := &model{
app: app,
+1 -1
View File
@@ -107,7 +107,7 @@ func (p *runtimePool) takeIdle(agent string) runtimeRunner {
}
}
func (p *runtimePool) channelFor(agent string) chan runtimeRunner {
func (p *runtimePool) channelFor(agent string) chan<- runtimeRunner {
p.mu.Lock()
defer p.mu.Unlock()
ch, ok := p.idle[agent]
+1 -1
View File
@@ -17,7 +17,7 @@ import (
// cancelled, then floods more events than any buffer holds before closing.
// done is closed only once every event has been consumed, so Run can only
// observe it closed if it cancelled the producer and drained the stream.
func stallingRunStream(triggers []runtime.Event, done chan struct{}) func(context.Context, *session.Session) <-chan runtime.Event {
func stallingRunStream(triggers []runtime.Event, done chan<- struct{}) func(context.Context, *session.Session) <-chan runtime.Event {
return func(ctx context.Context, _ *session.Session) <-chan runtime.Event {
ch := make(chan runtime.Event)
go func() {
+2 -2
View File
@@ -114,10 +114,10 @@ func TestManager_Query_UsageAggregation(t *testing.T) {
// until release is closed.
type blockingStrategy struct {
started chan struct{}
release chan struct{}
release <-chan struct{}
}
func newBlockingStrategy(release chan struct{}) *blockingStrategy {
func newBlockingStrategy(release <-chan struct{}) *blockingStrategy {
return &blockingStrategy{started: make(chan struct{}), release: release}
}
+1 -1
View File
@@ -134,7 +134,7 @@ func (b *elicitationBridge) send(ctx context.Context, ev Event) (err error) {
// process with a send-on-closed-channel panic; #3584 bounded the wait (send
// used to have no ctx at all and could block indefinitely) and moved the
// caller onto a detached goroutine so this can never stall the request path.
func (b *elicitationBridge) restoreAndClose(current, previous chan Event) {
func (b *elicitationBridge) restoreAndClose(current chan<- Event, previous chan Event) {
b.mu.Lock()
defer b.mu.Unlock()
b.ch = previous
+4 -4
View File
@@ -26,14 +26,14 @@ type EventSink interface {
// is dropped, since a closed channel signals that the consumer has
// gone away.
type channelSink struct {
ch chan Event
ch chan<- Event
}
// NewChannelSink returns an [EventSink] that writes to ch. This is the
// standard bridge between the runtime's EventSink-based internals and
// external callers that create raw event channels (e.g. app.go,
// tests).
func NewChannelSink(ch chan Event) EventSink {
func NewChannelSink(ch chan<- Event) EventSink {
return &channelSink{ch: ch}
}
@@ -49,7 +49,7 @@ func (s *channelSink) Emit(e Event) {
// regular runtime code should use the blocking [channelSink] so
// back-pressure is preserved.
type nonBlockingChannelSink struct {
ch chan Event
ch chan<- Event
}
// nonBlocking returns a non-blocking sink for sink. If sink wraps a
@@ -80,7 +80,7 @@ func (s nonBlockingChannelSink) Emit(e Event) {
// should use the unbounded blocking [channelSink] instead, so ordinary
// back-pressure is preserved.
type boundedChannelSink struct {
ch chan Event
ch chan<- Event
wait time.Duration
}
+1 -1
View File
@@ -215,7 +215,7 @@ func (r *LocalRuntime) emitHookDrivenShutdown(
// is bounded rather than unbounded). Consumers must still rely on the channel
// close, not on receiving StreamStopped, as the one guaranteed terminal
// signal.
func (r *LocalRuntime) finalizeEventChannel(ctx context.Context, sess *session.Session, reason string, finishReason chat.FinishReason, prevElicitationCh, events chan Event) {
func (r *LocalRuntime) finalizeEventChannel(ctx context.Context, sess *session.Session, reason string, finishReason chat.FinishReason, prevElicitationCh chan Event, events chan<- Event) {
a := r.resolveSessionAgent(sess)
if ctx.Err() != nil && reason == "" {
+2 -2
View File
@@ -538,7 +538,7 @@ func awaitEvent[T Event](t *testing.T, ch <-chan Event, what string) T {
}
}
func requirePlanChanged(t *testing.T, events chan Event) *PlanChangedEvent {
func requirePlanChanged(t *testing.T, events <-chan Event) *PlanChangedEvent {
t.Helper()
select {
case ev := <-events:
@@ -551,7 +551,7 @@ func requirePlanChanged(t *testing.T, events chan Event) *PlanChangedEvent {
}
}
func requireNoEvent(t *testing.T, events chan Event) {
func requireNoEvent(t *testing.T, events <-chan Event) {
t.Helper()
select {
case ev := <-events:
+1 -1
View File
@@ -52,7 +52,7 @@ func recordingTool(name string, executed *bool) []tools.Tool {
// further events are produced); used after processToolCalls returns.
// Distinct from drainEvents in loop_steps_test.go, which is
// non-blocking and intended for still-open channels.
func collectClosedEvents(ch chan Event) []Event {
func collectClosedEvents(ch <-chan Event) []Event {
var out []Event
for ev := range ch {
out = append(out, ev)
+1 -1
View File
@@ -1047,7 +1047,7 @@ func TestSessionWithoutUserMessage(t *testing.T) {
// --- Tool setup failure handling tests ---
func collectEvents(ch chan Event) []Event {
func collectEvents(ch <-chan Event) []Event {
n := len(ch)
evs := make([]Event, 0, n)
for range n {
+1 -1
View File
@@ -599,7 +599,7 @@ func TestHandleStream_ContextCancellation(t *testing.T) {
// agentChoiceText drains every buffered event and concatenates the
// AgentChoice content, i.e. exactly what a live consumer (TUI/API) rendered.
func agentChoiceText(ch chan Event) string {
func agentChoiceText(ch <-chan Event) string {
var b strings.Builder
for {
select {
+1 -1
View File
@@ -48,7 +48,7 @@ func newRAGAgent(name string, toolsets ...tools.ToolSet) *agent.Agent {
)
}
func requireRAGProgress(t *testing.T, events chan Event) *RAGIndexingProgressEvent {
func requireRAGProgress(t *testing.T, events <-chan Event) *RAGIndexingProgressEvent {
t.Helper()
select {
case ev := <-events:
+1 -1
View File
@@ -118,7 +118,7 @@ func (s *Supervisor) SpawnSession(ctx context.Context, workingDir string) (strin
// subscribeWithRouting subscribes to app events and wraps them with session ID.
// Registration precedes startup; terminal delivery waits for the program.
func (s *Supervisor) subscribeWithRouting(ctx context.Context, a *app.App, sessionID string, ready chan struct{}) {
func (s *Supervisor) subscribeWithRouting(ctx context.Context, a *app.App, sessionID string, ready chan<- struct{}) {
prepare := func(msg tea.Msg, generation uint64) tea.Msg {
s.mu.RLock()
defer s.mu.RUnlock()