Repository: alecthomas/gometalinter (go)

Issue:
Should invoke linters once with multiple paths if the linter supports it
gometalinter passes the `...` wildcard through to linters that support it, but seems to always pass only one path per linter invocation.

When operating on many packages, this makes it a lot slower than it has to be. An interesting case where this crops up is enabling the `GO15VENDOREXPERIMENT` support (which skips `/vendor/` directories but also defeats the previously mentioned optimization of passing through `...` wildcards) and passing one or more wildcard paths. On a repository I'm working on that currently contains 172 total packages (according to `go list ./...`) of which only 15 are not below a `/vendor/` directory, `gometalinter ./...` is actually faster without `GO15VENDOREXPERIMENT` despite having to lint and spit out about 8000 extra lint warnings for the vendored code. When passing 4 wildcard paths that expand to 14 packages (none of them vendored), it's about 3 times faster without `GO15VENDOREXPERIMENT`.

Although it would help if the `GO15VENDOREXPERIMENT` detected wildcard paths without vendored packages and passed through the wildcard for those, I suspect the better fix is to expand all paths up front and run child linters that support multiple paths/packages once. That should speed up more cases than making the ellipsis handling smarter. The main downsides are having to keep track of which linters can be invoked this way, and some additional complexity in gometalinter.

A cursory check shows all linters except golint that support ellipsis also support multiple paths. As golint is fairly fast anyway, it might make sense to replace the ellipsis support with this instead of having both. I doubt any of the child linters are faster than gometalinter at expanding wildcards, so doing that for them probably will not hurt performance if they still get all paths in one shot.

I haven't looked at how difficult this would be to implement, but I'm happy to do that if this sounds like something you'd merge.


Pull request:
Run one process per linter
Fixes #167
From what I can tell, all linters now support multiple paths.

This PR removes the path handling and always executes one process per linter with all the paths.

This reduces the runtime of `gometalinter` of `docker/cli` from 100s down to less than 8s.

Patch:
diff --git a/config.go b/config.go
index 9633383..9fffd6f 100644
--- a/config.go
+++ b/config.go
@@ -89,52 +89,49 @@ var (
 		"unused":      "honnef.co/go/tools/cmd/unused",
 		"varcheck":    "github.com/opennota/check/cmd/varcheck",
 	}
-	acceptsEllipsis = map[string]bool{
-		"aligncheck":  true,
-		"errcheck":    true,
-		"golint":      true,
-		"gosimple":    true,
-		"interfacer":  true,
-		"megacheck":   true,
-		"staticcheck": true,
-		"structcheck": true,
-		"test":        true,
-		"varcheck":    true,
-		"unconvert":   true,
-	}
 	slowLinters = []string{"structcheck", "varcheck", "errcheck", "aligncheck", "testify", "test", "interfacer", "unconvert", "deadcode", "safesql", "staticcheck", "unparam", "unused", "gosimple", "megacheck"}
 	sortKeys    = []string{"none", "path", "line", "column", "severity", "message", "linter"}
 
+	linterTakesFiles = newStringSet([]string{
+		"dupl",
+		"gofmt",
+		"goimports",
+		"lll",
+		"misspell",
+		"vet",
+		"vetshadow",
+	}...)
+
 	// Linter definitions.
 	linterDefinitions = map[string]string{
-		"aligncheck":  `aligncheck {path}:^(?:[^:]+: )?(?P<path>.*?\.go):(?P<line>\d+):(?P<col>\d+):\s*(?P<message>.+)$`,
-		"deadcode":    `deadcode {path}:^deadcode: (?P<path>.*?\.go):(?P<line>\d+):(?P<col>\d+):\s*(?P<message>.*)$`,
-		"dupl":        `dupl -plumbing -threshold {duplthreshold} {path}/*.go:^(?P<path>.*?\.go):(?P<line>\d+)-\d+:\s*(?P<message>.*)$`,
-		"errcheck":    `errcheck -abspath {path}:PATH:LINE:COL:MESSAGE`,
-		"gas":         `gas -fmt=csv {path}/*.go:^(?P<path>.*?\.go),(?P<line>\d+),(?P<message>[^,]+,[^,]+,[^,]+)`,
-		"goconst":     `goconst -min-occurrences {min_occurrences} -min-length {min_const_length} {path}:PATH:LINE:COL:MESSAGE`,
-		"gocyclo":     `gocyclo -over {mincyclo} {path}:^(?P<cyclo>\d+)\s+\S+\s(?P<function>\S+)\s+(?P<path>.*?\.go):(?P<line>\d+):(\d+)$`,
-		"gofmt":       `gofmt -l -s {path}/*.go:^(?P<path>.*?\.go)$`,
-		"goimports":   `goimports -l {path}/*.go:^(?P<path>.*?\.go)$`,
-		"golint":      "golint -min_confidence {min_confidence} {path}:PATH:LINE:COL:MESSAGE",
-		"gosimple":    "gosimple {path}:PATH:LINE:COL:MESSAGE",
-		"gotype":      "gotype -e {tests=-a} {path}:PATH:LINE:COL:MESSAGE",
-		"ineffassign": `ineffassign -n {path}:PATH:LINE:COL:MESSAGE`,
-		"interfacer":  `interfacer {path}:PATH:LINE:COL:MESSAGE`,
-		"lll":         `lll -g -l {maxlinelength} {path}/*.go:PATH:LINE:MESSAGE`,
-		"megacheck":   "megacheck {path}:PATH:LINE:COL:MESSAGE",
-		"misspell":    "misspell -j 1 {path}/*.go:PATH:LINE:COL:MESSAGE",
-		"safesql":     `safesql {path}:^- (?P<path>.*?\.go):(?P<line>\d+):(?P<col>\d+)$`,
-		"staticcheck": "staticcheck {path}:PATH:LINE:COL:MESSAGE",
-		"structcheck": `structcheck {tests=-t} {path}:^(?:[^:]+: )?(?P<path>.*?\.go):(?P<line>\d+):(?P<col>\d+):\s*(?P<message>.+)$`,
-		"test":        `go test {path}:^--- FAIL: .*$\s+(?P<path>.*?\.go):(?P<line>\d+): (?P<message>.*)$`,
-		"testify":     `go test {path}:Location:\s+(?P<path>.*?\.go):(?P<line>\d+)$\s+Error:\s+(?P<message>[^\n]+)`,
-		"unconvert":   "unconvert {path}:PATH:LINE:COL:MESSAGE",
-		"unparam":     `unparam {path}:PATH:LINE:COL:MESSAGE`,
-		"unused":      `unused {path}:PATH:LINE:COL:MESSAGE`,
-		"varcheck":    `varcheck {path}:^(?:[^:]+: )?(?P<path>.*?\.go):(?P<line>\d+):(?P<col>\d+):\s*(?P<message>.*)$`,
-		"vet":         `go tool vet {path}/*.go:` + vetRe,
-		"vetshadow":   `go tool vet --shadow {path}/*.go:` + vetRe,
+		"aligncheck":  `aligncheck:^(?:[^:]+: )?(?P<path>.*?\.go):(?P<line>\d+):(?P<col>\d+):\s*(?P<message>.+)$`,
+		"deadcode":    `deadcode:^deadcode: (?P<path>.*?\.go):(?P<line>\d+):(?P<col>\d+):\s*(?P<message>.*)$`,
+		"dupl":        `dupl -plumbing -threshold {duplthreshold}:^(?P<path>.*?\.go):(?P<line>\d+)-\d+:\s*(?P<message>.*)$`,
+		"errcheck":    `errcheck -abspath:PATH:LINE:COL:MESSAGE`,
+		"gas":         `gas -fmt=csv:^(?P<path>.*?\.go),(?P<line>\d+),(?P<message>[^,]+,[^,]+,[^,]+)`,
+		"goconst":     `goconst -min-occurrences {min_occurrences} -min-length {min_const_length}:PATH:LINE:COL:MESSAGE`,
+		"gocyclo":     `gocyclo -over {mincyclo}:^(?P<cyclo>\d+)\s+\S+\s(?P<function>\S+)\s+(?P<path>.*?\.go):(?P<line>\d+):(\d+)$`,
+		"gofmt":       `gofmt -l -s:^(?P<path>.*?\.go)$`,
+		"goimports":   `goimports -l:^(?P<path>.*?\.go)$`,
+		"golint":      "golint -min_confidence {min_confidence}:PATH:LINE:COL:MESSAGE",
+		"gosimple":    "gosimple:PATH:LINE:COL:MESSAGE",
+		"gotype":      "gotype -e {tests=-a}:PATH:LINE:COL:MESSAGE",
+		"ineffassign": `ineffassign -n:PATH:LINE:COL:MESSAGE`,
+		"interfacer":  `interfacer:PATH:LINE:COL:MESSAGE`,
+		"lll":         `lll -g -l {maxlinelength}:PATH:LINE:MESSAGE`,
+		"megacheck":   "megacheck:PATH:LINE:COL:MESSAGE",
+		"misspell":    "misspell -j 1:PATH:LINE:COL:MESSAGE",
+		"safesql":     `safesql:^- (?P<path>.*?\.go):(?P<line>\d+):(?P<col>\d+)$`,
+		"staticcheck": "staticcheck:PATH:LINE:COL:MESSAGE",
+		"structcheck": `structcheck {tests=-t}:^(?:[^:]+: )?(?P<path>.*?\.go):(?P<line>\d+):(?P<col>\d+):\s*(?P<message>.+)$`,
+		"test":        `go test:^--- FAIL: .*$\s+(?P<path>.*?\.go):(?P<line>\d+): (?P<message>.*)$`,
+		"testify":     `go test:Location:\s+(?P<path>.*?\.go):(?P<line>\d+)$\s+Error:\s+(?P<message>[^\n]+)`,
+		"unconvert":   "unconvert:PATH:LINE:COL:MESSAGE",
+		"unparam":     `unparam:PATH:LINE:COL:MESSAGE`,
+		"unused":      `unused:PATH:LINE:COL:MESSAGE`,
+		"varcheck":    `varcheck:^(?:[^:]+: )?(?P<path>.*?\.go):(?P<line>\d+):(?P<col>\d+):\s*(?P<message>.*)$`,
+		"vet":         `go tool vet:` + vetRe,
+		"vetshadow":   `go tool vet --shadow:` + vetRe,
 	}
 
 	pathsArg = kingpin.Arg("path", "Directories to lint. Defaults to \".\". <path>/... will recurse.").Strings()
diff --git a/main.go b/main.go
index 3d8621e..22489a4 100644
--- a/main.go
+++ b/main.go
@@ -264,14 +264,6 @@ func formatSeverity() string {
 
 type Vars map[string]string
 
-func (v Vars) Copy() Vars {
-	out := Vars{}
-	for k, v := range v {
-		out[k] = v
-	}
-	return out
-}
-
 func (v Vars) Replace(s string) string {
 	for k, v := range v {
 		prefix := regexp.MustCompile(fmt.Sprintf("{%s=([^}]*)}", k))
@@ -286,8 +278,6 @@ func (v Vars) Replace(s string) string {
 }
 
 func main() {
-	// Linters are by their very nature, short lived, so disable GC.
-	// Reduced (user) linting time on kingpin from 0.97s to 0.64s.
 	kingpin.CommandLine.Help = fmt.Sprintf(`Aggregate and normalise the output of a whole bunch of Go linters.
 
 PlaceHolder linters:
@@ -314,7 +304,7 @@ Severity override map (default is "warning"):
 
 	linters := lintersFromFlags()
 	status := 0
-	issues, errch := runLinters(linters, paths, *pathsArg, config.Concurrency, exclude, include)
+	issues, errch := runLinters(linters, paths, config.Concurrency, exclude, include)
 	if config.JSON {
 		status |= outputToJSON(issues)
 	} else if config.Checkstyle {
@@ -341,7 +331,10 @@ func processConfig(config *Config) (include *regexp.Regexp, exclude *regexp.Rege
 	tmpl, err := template.New("output").Parse(config.Format)
 	kingpin.FatalIfError(err, "invalid format %q", config.Format)
 	formatTemplate = tmpl
+
 	if !config.EnableGC {
+		// Linters are by their very nature, short lived, so disable GC.
+		// Reduced (user) linting time on kingpin from 0.97s to 0.64s.
 		_ = os.Setenv("GOGC", "off")
 	}
 	if config.VendoredLinters && config.Install && config.Update {
@@ -412,47 +405,50 @@ func outputToJSON(issues chan *Issue) int {
 	return status
 }
 
-func runLinters(linters map[string]*Linter, paths, ellipsisPaths []string, concurrency int, exclude *regexp.Regexp, include *regexp.Regexp) (chan *Issue, chan error) {
-	errch := make(chan error, len(linters)*(len(paths)+len(ellipsisPaths)))
+func runLinters(linters map[string]*Linter, paths []string, concurrency int, exclude, include *regexp.Regexp) (chan *Issue, chan error) {
+	errch := make(chan error)
 	concurrencych := make(chan bool, concurrency)
 	incomingIssues := make(chan *Issue, 1000000)
-	directives := newDirectiveParser()
-	processedIssues := filterIssuesViaDirectives(directives, maybeSortIssues(maybeAggregateIssues(incomingIssues)))
+	processedIssues := filterIssuesViaDirectives(
+		newDirectiveParser(),
+		maybeSortIssues(maybeAggregateIssues(incomingIssues)))
+
+	vars := Vars{
+		"duplthreshold":    fmt.Sprintf("%d", config.DuplThreshold),
+		"mincyclo":         fmt.Sprintf("%d", config.Cyclo),
+		"maxlinelength":    fmt.Sprintf("%d", config.LineLength),
+		"min_confidence":   fmt.Sprintf("%f", config.MinConfidence),
+		"min_occurrences":  fmt.Sprintf("%d", config.MinOccurrences),
+		"min_const_length": fmt.Sprintf("%d", config.MinConstLength),
+		"tests":            "",
+	}
+	if config.Test {
+		vars["tests"] = "-t"
+	}
+
 	wg := &sync.WaitGroup{}
 	for _, linter := range linters {
-		// Recreated in each loop because it is mutated by executeLinter().
-		vars := Vars{
-			"duplthreshold":    fmt.Sprintf("%d", config.DuplThreshold),
-			"mincyclo":         fmt.Sprintf("%d", config.Cyclo),
-			"maxlinelength":    fmt.Sprintf("%d", config.LineLength),
-			"min_confidence":   fmt.Sprintf("%f", config.MinConfidence),
-			"min_occurrences":  fmt.Sprintf("%d", config.MinOccurrences),
-			"min_const_length": fmt.Sprintf("%d", config.MinConstLength),
-			"tests":            "",
-		}
-		if config.Test {
-			vars["tests"] = "-t"
-		}
-		linterPaths := paths
-		// Most linters don't exclude vendor paths when recursing, so we don't use ... paths.
-		if acceptsEllipsis[linter.Name] && !config.Vendor && len(ellipsisPaths) > 0 {
-			linterPaths = ellipsisPaths
-		}
-		for _, path := range linterPaths {
+		deadline := time.After(config.Deadline)
+		state := &linterState{
+			Linter:   linter,
+			issues:   incomingIssues,
+			paths:    paths,
+			vars:     vars,
+			exclude:  exclude,
+			include:  include,
+			deadline: deadline,
+		}
+
+		partitions, err := state.Partitions()
+		if err != nil {
+			errch <- err
+			continue
+		}
+		for _, args := range partitions {
 			wg.Add(1)
-			deadline := time.After(config.Deadline)
-			state := &linterState{
-				Linter:   linter,
-				issues:   incomingIssues,
-				path:     path,
-				vars:     vars.Copy(),
-				exclude:  exclude,
-				include:  include,
-				deadline: deadline,
-			}
 			go func() {
 				concurrencych <- true
-				err := executeLinter(state)
+				err := executeLinter(state, args)
 				if err != nil {
 					errch <- err
 				}
@@ -470,15 +466,12 @@ func runLinters(linters map[string]*Linter, paths, ellipsisPaths []string, concu
 	return processedIssues, errch
 }
 
-// nolint: gocyclo
 func expandPaths(paths, skip []string) []string {
 	if len(paths) == 0 {
-		paths = []string{"."}
-	}
-	skipMap := map[string]bool{}
-	for _, name := range skip {
-		skipMap[name] = true
+		return []string{"."}
 	}
+
+	skipPath := newPathFilter(skip)
 	dirs := map[string]bool{}
 	for _, path := range paths {
 		if strings.HasSuffix(path, "/...") {
@@ -489,8 +482,7 @@ func expandPaths(paths, skip []string) []string {
 					return err
 				}
 
-				base := filepath.Base(p)
-				skip := skipMap[base] || skipMap[p] || (strings.ContainsAny(base[0:1], "_.") && base != "." && base != "..")
+				skip := skipPath(p)
 				if i.IsDir() {
 					if skip {
 						return filepath.SkipDir
@@ -506,7 +498,7 @@ func expandPaths(paths, skip []string) []string {
 	}
 	out := make([]string, 0, len(dirs))
 	for d := range dirs {
-		out = append(out, d)
+		out = append(out, relativePackagePath(d))
 	}
 	sort.Strings(out)
 	for _, d := range out {
@@ -515,6 +507,29 @@ func expandPaths(paths, skip []string) []string {
 	return out
 }
 
+func newPathFilter(skip []string) func(string) bool {
+	filter := map[string]bool{}
+	for _, name := range skip {
+		filter[name] = true
+	}
+
+	return func(path string) bool {
+		base := filepath.Base(path)
+		if filter[base] || filter[path] {
+			return true
+		}
+		return base != "." && base != ".." && strings.ContainsAny(base[0:1], "_.")
+	}
+}
+
+func relativePackagePath(dir string) string {
+	if filepath.IsAbs(dir) || strings.HasPrefix(dir, ".") {
+		return dir
+	}
+	// package names must start with a ./
+	return "./" + dir
+}
+
 func makeInstallCommand(linters ...string) []string {
 	cmd := []string{"get"}
 	if config.VendoredLinters {
@@ -614,9 +629,12 @@ func maybeSortIssues(issues chan *Issue) chan *Issue {
 	return out
 }
 
+// MaxCommandBytes is the maximum number of bytes used when executing a command
+const MaxCommandBytes = 32000
+
 type linterState struct {
 	*Linter
-	path     string
+	paths    []string
 	issues   chan *Issue
 	vars     Vars
 	exclude  *regexp.Regexp
@@ -624,21 +642,43 @@ type linterState struct {
 	deadline <-chan time.Time
 }
 
-func (l *linterState) InterpolatedCommand() string {
-	vars := l.vars.Copy()
-	if l.ShouldChdir() {
-		vars["path"] = "."
-	} else {
-		vars["path"] = l.path
+func (l *linterState) Paths() []string {
+	if !linterTakesFiles.contains(l.Name) {
+		return l.paths
 	}
-	return vars.Replace(l.Command)
+	filePaths := []string{}
+	for _, dir := range l.paths {
+		// ignore error because the glob pattern is hardcoded
+		paths, _ := filepath.Glob(filepath.Join(dir, "*.go"))
+		filePaths = append(filePaths, paths...)
+	}
+	return filePaths
 }
 
-func (l *linterState) ShouldChdir() bool {
-	return config.Vendor || !strings.HasSuffix(l.path, "/...") || !strings.Contains(l.Command, "{path}")
+func (l *linterState) Partitions() ([][]string, error) {
+	command := l.vars.Replace(l.Command)
+	cmd, args, err := parseCommand(command, l.Paths())
+	if err != nil {
+		return nil, err
+	}
+
+	partitions := [][]string{}
+	current := []string{cmd}
+	size := len(cmd)
+	for _, arg := range args {
+		if size+len(arg)+1 >= MaxCommandBytes {
+			partitions = append(partitions, current)
+			current = []string{cmd, arg}
+			size = len(cmd) + len(arg) + 1
+			continue
+		}
+		current = append(current, arg)
+		size += len(arg)
+	}
+	return append(partitions, current), nil
 }
 
-func parseCommand(dir, command string) (string, []string, error) {
+func parseCommand(command string, paths []string) (string, []string, error) {
 	args, err := shlex.Split(command)
 	if err != nil {
 		return "", nil, err
@@ -650,45 +690,23 @@ func parseCommand(dir, command string) (string, []string, error) {
 	if err != nil {
 		return "", nil, err
 	}
-	out := []string{}
-	for _, arg := range args[1:] {
-		if strings.Contains(arg, "*") {
-			pattern := filepath.Join(dir, arg)
-			globbed, err := filepath.Glob(pattern)
-			if err != nil {
-				return "", nil, err
-			}
-			for i, g := range globbed {
-				if strings.HasPrefix(g, dir+string(filepath.Separator)) {
-					globbed[i] = g[len(dir)+1:]
-				}
-			}
-			out = append(out, globbed...)
-		} else {
-			out = append(out, arg)
-		}
-	}
-	return exe, out, nil
+	return exe, append(args[1:], paths...), nil
 }
 
-func executeLinter(state *linterState) error {
-	debug("linting with %s: %s (on %s)", state.Name, state.Command, state.path)
+func executeLinter(state *linterState, args []string) error {
+	if len(args) == 0 {
+		return fmt.Errorf("missing linter command")
+	}
+	debug("linting with %s: %s", state.Name, state.Command)
 
 	start := time.Now()
-	command := state.InterpolatedCommand()
-	exe, args, err := parseCommand(state.path, command)
-	if err != nil {
-		return err
-	}
-	debug("executing %s %q", exe, args)
+	debug("executing %s", strings.Join(args, " "))
 	buf := bytes.NewBuffer(nil)
-	cmd := exec.Command(exe, args...) // nolint: gas
-	if state.ShouldChdir() {
-		cmd.Dir = state.path
-	}
+	command := args[0]
+	cmd := exec.Command(command, args[1:]...) // nolint: gas
 	cmd.Stdout = buf
 	cmd.Stderr = buf
-	err = cmd.Start()
+	err := cmd.Start()
 	if err != nil {
 		return fmt.Errorf("failed to execute linter %s: %s", command, err)
 	}
@@ -704,8 +722,8 @@ func executeLinter(state *linterState) error {
 	case <-done:
 
 	case <-state.deadline:
-		err = fmt.Errorf("deadline exceeded by linter %s on %s (try increasing --deadline)",
-			state.Name, state.path)
+		err = fmt.Errorf("deadline exceeded by linter %s (try increasing --deadline)",
+			state.Name)
 		kerr := cmd.Process.Kill()
 		if kerr != nil {
 			warning("failed to kill %s: %s", state.Name, kerr)
@@ -714,7 +732,7 @@ func executeLinter(state *linterState) error {
 	}
 
 	if err != nil {
-		debug("warning: %s returned %s", command, err)
+		debug("warning: %s returned %s: %s", command, err, buf.String())
 	}
 
 	processOutput(state, buf.Bytes())
@@ -723,23 +741,6 @@ func executeLinter(state *linterState) error {
 	return nil
 }
 
-func (l *linterState) fixPath(path string) string {
-	lpath := strings.TrimSuffix(l.path, "...")
-	labspath, _ := filepath.Abs(lpath)
-
-	if !l.ShouldChdir() {
-		path = strings.TrimPrefix(path, lpath)
-	}
-
-	if !filepath.IsAbs(path) {
-		path, _ = filepath.Abs(filepath.Join(labspath, path))
-	}
-	if strings.HasPrefix(path, labspath) {
-		return filepath.Join(lpath, strings.TrimPrefix(path, labspath))
-	}
-	return path
-}
-
 func lintersFromFlags() map[string]*Linter {
 	out := map[string]*Linter{}
 	config.Enable = replaceWithMegacheck(config.Enable)
@@ -794,6 +795,12 @@ func processOutput(state *linterState, out []byte) {
 	re := state.regex
 	all := re.FindAllSubmatchIndex(out, -1)
 	debug("%s hits %d: %s", state.Name, len(all), state.Pattern)
+
+	cwd, err := os.Getwd()
+	if err != nil {
+		warning("failed to get working directory %s", err)
+	}
+
 	for _, indices := range all {
 		group := [][]byte{}
 		for i := 0; i < len(indices); i += 2 {
@@ -816,7 +823,7 @@ func processOutput(state *linterState, out []byte) {
 			}
 			switch name {
 			case "path":
-				issue.Path = state.fixPath(part)
+				issue.Path = relativePath(cwd, part)
 
 			case "line":
 				n, err := strconv.ParseInt(part, 10, 32)
@@ -853,6 +860,18 @@ func processOutput(state *linterState, out []byte) {
 	return
 }
 
+func relativePath(root, path string) string {
+	if !filepath.IsAbs(path) {
+		return path
+	}
+	relative, err := filepath.Rel(root, path)
+	if err != nil {
+		warning("failed to make %s a relative path: %s", path, err)
+		return path
+	}
+	return relative
+}
+
 func findVendoredLinters() string {
 	gopaths := strings.Split(getGoPath(), string(os.PathListSeparator))
 	for _, home := range vendoredSearchPaths {
@@ -865,7 +884,6 @@ func findVendoredLinters() string {
 		}
 	}
 	return ""
-
 }
 
 // Go 1.8 compatible GOPATH.
@@ -938,3 +956,33 @@ func configureEnvironment() {
 	}
 	debug("GOBIN=%s", os.Getenv("GOBIN"))
 }
+
+// TODO: replace dirs in expandPaths() and addPath() with a set (others?)
+type stringSet struct {
+	items map[string]struct{}
+}
+
+func newStringSet(items ...string) *stringSet {
+	setItems := make(map[string]struct{}, len(items))
+	for _, item := range items {
+		setItems[item] = struct{}{}
+	}
+	return &stringSet{items: setItems}
+}
+
+func (s *stringSet) add(item string) {
+	s.items[item] = struct{}{}
+}
+
+func (s *stringSet) contains(item string) bool {
+	_, exists := s.items[item]
+	return exists
+}
+
+func (s *stringSet) asSlice() []string {
+	items := []string{}
+	for item := range s.items {
+		items = append(items, item)
+	}
+	return items
+}


Test patch:
diff --git a/main_test.go b/main_test.go
index 15fb069..8b0d78d 100644
--- a/main_test.go
+++ b/main_test.go
@@ -1,12 +1,126 @@
 package main
 
 import (
+	"io/ioutil"
+	"os"
+	"path/filepath"
 	"sort"
 	"testing"
 
+	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
+func TestRelativePackagePath(t *testing.T) {
+	var testcases = []struct {
+		dir      string
+		expected string
+	}{
+		{
+			dir:      "/abs/path",
+			expected: "/abs/path",
+		},
+		{
+			dir:      ".",
+			expected: ".",
+		},
+		{
+			dir:      "./foo",
+			expected: "./foo",
+		},
+		{
+			dir:      "relative/path",
+			expected: "./relative/path",
+		},
+	}
+
+	for _, testcase := range testcases {
+		assert.Equal(t, testcase.expected, relativePackagePath(testcase.dir))
+	}
+}
+
+func TestExpandPathsNoPaths(t *testing.T) {
+	paths := expandPaths(nil, nil)
+	assert.Equal(t, []string{"."}, paths)
+}
+
+func TestExpandPathsNoExpands(t *testing.T) {
+	// Non-expanded paths should not be filtered by the skip path list
+	paths := expandPaths([]string{".", "foo", "foo/bar"}, []string{"foo/bar"})
+	expected := []string{".", "./foo", "./foo/bar"}
+	assert.Equal(t, expected, paths)
+}
+
+func TestExpandPathsWithExpands(t *testing.T) {
+	tmpdir, cleanup := setupTempDir(t)
+	defer cleanup()
+
+	mkGoFile(t, tmpdir)
+	mkDir(t, tmpdir, "exclude")
+	mkDir(t, tmpdir, "other", "exclude")
+	mkDir(t, tmpdir, "include")
+	mkDir(t, tmpdir, "include", "foo")
+	mkDir(t, tmpdir, "duplicate")
+	mkDir(t, tmpdir, ".exclude")
+	mkDir(t, tmpdir, "include", ".exclude")
+	mkDir(t, tmpdir, "_exclude")
+	mkDir(t, tmpdir, "include", "_exclude")
+
+	filterPaths := []string{"exclude", "other/exclude"}
+	paths := expandPaths([]string{"./...", "foo", "duplicate"}, filterPaths)
+
+	expected := []string{
+		".",
+		"./duplicate",
+		"./foo",
+		"./include",
+		"./include/foo",
+	}
+	assert.Equal(t, expected, paths)
+}
+
+func setupTempDir(t *testing.T) (string, func()) {
+	tmpdir, err := ioutil.TempDir("", "test-expand-paths")
+	require.NoError(t, err)
+
+	oldwd, err := os.Getwd()
+	require.NoError(t, err)
+	require.NoError(t, os.Chdir(tmpdir))
+
+	return tmpdir, func() {
+		os.RemoveAll(tmpdir)
+		require.NoError(t, os.Chdir(oldwd))
+	}
+}
+
+func mkDir(t *testing.T, paths ...string) {
+	fullPath := filepath.Join(paths...)
+	require.NoError(t, os.MkdirAll(fullPath, 0755))
+	mkGoFile(t, fullPath)
+}
+
+func mkGoFile(t *testing.T, path string) {
+	content := []byte("package foo")
+	err := ioutil.WriteFile(filepath.Join(path, "file.go"), content, 0644)
+	require.NoError(t, err)
+}
+
+func TestLinterStatePaths(t *testing.T) {
+	tmpdir, cleanup := setupTempDir(t)
+	defer cleanup()
+
+	mkGoFile(t, tmpdir)
+	mkDir(t, tmpdir, "two")
+	mkDir(t, tmpdir, "two", "three")
+
+	state := linterState{
+		Linter: &Linter{Name: "gofmt"},
+		paths:  []string{".", "./two", "./two/three"},
+	}
+	expected := []string{"file.go", "two/file.go", "two/three/file.go"}
+	assert.Equal(t, expected, state.Paths())
+}
+
 func TestSortedIssues(t *testing.T) {
 	actual := []*Issue{
 		{Path: "b.go", Line: 5},
diff --git a/regressiontests/support.go b/regressiontests/support.go
index b1ce418..243e57c 100644
--- a/regressiontests/support.go
+++ b/regressiontests/support.go
@@ -1,6 +1,7 @@
 package regressiontests
 
 import (
+	"bytes"
 	"encoding/json"
 	"fmt"
 	"io/ioutil"
@@ -12,6 +13,7 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 type Issue struct {
@@ -42,32 +44,28 @@ func (e Issues) Less(i, j int) bool { return e[i].String() < e[j].String() }
 func ExpectIssues(t *testing.T, linter string, source string, expected Issues, extraFlags ...string) {
 	// Write source to temporary directory.
 	dir, err := ioutil.TempDir(".", "gometalinter-")
-	if !assert.NoError(t, err) {
-		return
-	}
+	require.NoError(t, err)
 	defer os.RemoveAll(dir)
-	w, err := os.Create(filepath.Join(dir, "test.go"))
-	if !assert.NoError(t, err) {
-		return
-	}
-	defer os.Remove(w.Name())
-	_, err = w.WriteString(source)
-	_ = w.Close()
-	if !assert.NoError(t, err) {
-		return
-	}
+
+	testFile := filepath.Join(dir, "test.go")
+	err = ioutil.WriteFile(testFile, []byte(source), 0644)
+	require.NoError(t, err)
 
 	// Run gometalinter.
-	args := []string{"go", "run", "../main.go", "../directives.go", "../config.go", "../checkstyle.go", "../aggregate.go", "--disable-all", "--enable", linter, "--json", dir}
+	binary, cleanup := buildBinary(t)
+	defer cleanup()
+	args := []string{"-d", "--disable-all", "--enable", linter, "--json", dir}
 	args = append(args, extraFlags...)
-	cmd := exec.Command(args[0], args[1:]...)
-	if !assert.NoError(t, err) {
-		return
-	}
+	cmd := exec.Command(binary, args...)
+	errBuffer := new(bytes.Buffer)
+	cmd.Stderr = errBuffer
+	require.NoError(t, err)
+
 	output, _ := cmd.Output()
 	var actual Issues
 	err = json.Unmarshal(output, &actual)
 	if !assert.NoError(t, err) {
+		fmt.Printf("Stderr: %s\n", errBuffer)
 		fmt.Printf("Output: %s\n", output)
 		return
 	}
@@ -78,7 +76,7 @@ func ExpectIssues(t *testing.T, linter string, source string, expected Issues, e
 		if issue.Linter == linter || linter == "" {
 			// Normalise path.
 			issue.Path = "test.go"
-			issue.Message = strings.Replace(issue.Message, w.Name(), "test.go", -1)
+			issue.Message = strings.Replace(issue.Message, testFile, "test.go", -1)
 			issue.Message = strings.Replace(issue.Message, dir, "", -1)
 			actualForLinter = append(actualForLinter, issue)
 		}
@@ -86,5 +84,17 @@ func ExpectIssues(t *testing.T, linter string, source string, expected Issues, e
 	sort.Sort(expected)
 	sort.Sort(actualForLinter)
 
-	assert.Equal(t, expected, actualForLinter)
+	if !assert.Equal(t, expected, actualForLinter) {
+		fmt.Printf("Stderr: %s\n", errBuffer)
+		fmt.Printf("Output: %s\n", output)
+	}
+}
+
+func buildBinary(t *testing.T) (string, func()) {
+	tmpdir, err := ioutil.TempDir("", "regression-test")
+	require.NoError(t, err)
+	path := filepath.Join(tmpdir, "binary")
+	cmd := exec.Command("go", "build", "-o", path, "..")
+	require.NoError(t, cmd.Run())
+	return path, func() { os.RemoveAll(tmpdir) }
 }